This commit is contained in:
2026-01-20 17:12:56 +01:00
parent 1ba74f850c
commit f7797664bd
13 changed files with 86318 additions and 209 deletions

0
seq/cpu.prof Normal file
View File

42841
seq/ok Normal file

File diff suppressed because it is too large Load Diff

42841
seq/something Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ use crate::config::MAX_SLOTS;
use crate::engine::{
LinkState, PatternSnapshot, SeqCommand, SequencerSnapshot, SlotChange, StepSnapshot,
};
use crate::model::{self, Pattern, Rng, ScriptEngine, StepContext, Variables};
use crate::model::{self, Bank, Pattern, Rng, ScriptEngine, StepContext, Variables};
use crate::page::Page;
use crate::services::pattern_editor;
use crate::state::{
@@ -36,6 +36,8 @@ pub struct App {
pub variables: Variables,
pub rng: Rng,
pub clipboard: Option<arboard::Clipboard>,
pub copied_pattern: Option<Pattern>,
pub copied_bank: Option<Bank>,
pub audio: AudioSettings,
}
@@ -62,6 +64,8 @@ impl App {
rng,
script_engine,
clipboard: arboard::Clipboard::new().ok(),
copied_pattern: None,
copied_bank: None,
audio: AudioSettings::default(),
}
@@ -351,30 +355,6 @@ impl App {
}
}
pub fn is_pattern_queued(
&self,
bank: usize,
pattern: usize,
snapshot: &SequencerSnapshot,
) -> Option<bool> {
self.playback.queued_changes.iter().find_map(|c| match *c {
SlotChange::Add {
slot: _,
bank: b,
pattern: p,
} if b == bank && p == pattern => Some(true),
SlotChange::Remove { slot } => {
let s = snapshot.slot_data[slot];
if s.active && s.bank == bank && s.pattern == pattern {
Some(false)
} else {
None
}
}
_ => None,
})
}
pub fn toggle_pattern_playback(
&mut self,
bank: usize,
@@ -539,6 +519,74 @@ impl App {
self.ui.flash("Step deleted", 150);
}
pub fn reset_pattern(&mut self, bank: usize, pattern: usize) {
self.project_state.project.banks[bank].patterns[pattern] = Pattern::default();
self.project_state.mark_dirty(bank, pattern);
if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern {
self.load_step_to_editor();
}
self.ui.flash("Pattern reset", 150);
}
pub fn reset_bank(&mut self, bank: usize) {
self.project_state.project.banks[bank] = Bank::default();
for pattern in 0..self.project_state.project.banks[bank].patterns.len() {
self.project_state.mark_dirty(bank, pattern);
}
if self.editor_ctx.bank == bank {
self.load_step_to_editor();
}
self.ui.flash("Bank reset", 150);
}
pub fn copy_pattern(&mut self, bank: usize, pattern: usize) {
let pat = self.project_state.project.banks[bank].patterns[pattern].clone();
self.copied_pattern = Some(pat);
self.ui.flash("Pattern copied", 150);
}
pub fn paste_pattern(&mut self, bank: usize, pattern: usize) {
if let Some(src) = &self.copied_pattern {
let mut pat = src.clone();
pat.name = match &src.name {
Some(name) if !name.ends_with(" (copy)") => Some(format!("{} (copy)", name)),
Some(name) => Some(name.clone()),
None => Some("(copy)".to_string()),
};
self.project_state.project.banks[bank].patterns[pattern] = pat;
self.project_state.mark_dirty(bank, pattern);
if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern {
self.load_step_to_editor();
}
self.ui.flash("Pattern pasted", 150);
}
}
pub fn copy_bank(&mut self, bank: usize) {
let b = self.project_state.project.banks[bank].clone();
self.copied_bank = Some(b);
self.ui.flash("Bank copied", 150);
}
pub fn paste_bank(&mut self, bank: usize) {
if let Some(src) = &self.copied_bank {
let mut b = src.clone();
b.name = match &src.name {
Some(name) if !name.ends_with(" (copy)") => Some(format!("{} (copy)", name)),
Some(name) => Some(name.clone()),
None => Some("(copy)".to_string()),
};
self.project_state.project.banks[bank] = b;
for pattern in 0..self.project_state.project.banks[bank].patterns.len() {
self.project_state.mark_dirty(bank, pattern);
}
if self.editor_ctx.bank == bank {
self.load_step_to_editor();
}
self.ui.flash("Bank pasted", 150);
}
}
pub fn paste_step(&mut self, link: &LinkState) {
let text = self
.clipboard
@@ -717,6 +765,24 @@ impl App {
} => {
self.delete_step(bank, pattern, step);
}
AppCommand::ResetPattern { bank, pattern } => {
self.reset_pattern(bank, pattern);
}
AppCommand::ResetBank { bank } => {
self.reset_bank(bank);
}
AppCommand::CopyPattern { bank, pattern } => {
self.copy_pattern(bank, pattern);
}
AppCommand::PastePattern { bank, pattern } => {
self.paste_pattern(bank, pattern);
}
AppCommand::CopyBank { bank } => {
self.copy_bank(bank);
}
AppCommand::PasteBank { bank } => {
self.paste_bank(bank);
}
// Clipboard
AppCommand::CopyStep => self.copy_step(),

View File

@@ -4,6 +4,7 @@ use crate::engine::SlotChange;
use crate::model::PatternSpeed;
use crate::state::{Modal, PatternField};
#[allow(dead_code)]
pub enum AppCommand {
// Playback
TogglePlaying,
@@ -45,6 +46,27 @@ pub enum AppCommand {
pattern: usize,
step: usize,
},
ResetPattern {
bank: usize,
pattern: usize,
},
ResetBank {
bank: usize,
},
CopyPattern {
bank: usize,
pattern: usize,
},
PastePattern {
bank: usize,
pattern: usize,
},
CopyBank {
bank: usize,
},
PasteBank {
bank: usize,
},
// Clipboard
CopyStep,

View File

@@ -288,7 +288,7 @@ fn sequencer_loop(
slot_steps: [Arc<AtomicUsize>; MAX_SLOTS],
event_count: Arc<AtomicUsize>,
) {
let script_engine = ScriptEngine::new(variables, rng);
let script_engine = ScriptEngine::new(Arc::clone(&variables), rng);
let mut audio_state = AudioState::new();
let mut pattern_cache = PatternCache::new();
let mut runs_counter = RunsCounter::new();
@@ -413,6 +413,12 @@ fn sequencer_loop(
}
}
}
if let Some(new_tempo) = {
let mut vars = variables.lock().unwrap();
vars.remove("__tempo__").and_then(|v| v.as_float().ok())
} {
link.set_tempo(new_tempo);
}
}
}
}

View File

@@ -107,6 +107,70 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
_ => {}
}
}
Modal::ConfirmResetPattern {
bank,
pattern,
selected: _,
} => {
let (bank, pattern) = (*bank, *pattern);
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
ctx.dispatch(AppCommand::ResetPattern { bank, pattern });
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Left | KeyCode::Right => {
if let Modal::ConfirmResetPattern { selected, .. } = &mut ctx.app.ui.modal {
*selected = !*selected;
}
}
KeyCode::Enter => {
let do_reset =
if let Modal::ConfirmResetPattern { selected, .. } = &ctx.app.ui.modal {
*selected
} else {
false
};
if do_reset {
ctx.dispatch(AppCommand::ResetPattern { bank, pattern });
}
ctx.dispatch(AppCommand::CloseModal);
}
_ => {}
}
}
Modal::ConfirmResetBank { bank, selected: _ } => {
let bank = *bank;
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
ctx.dispatch(AppCommand::ResetBank { bank });
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Left | KeyCode::Right => {
if let Modal::ConfirmResetBank { selected, .. } = &mut ctx.app.ui.modal {
*selected = !*selected;
}
}
KeyCode::Enter => {
let do_reset =
if let Modal::ConfirmResetBank { selected, .. } = &ctx.app.ui.modal {
*selected
} else {
false
};
if do_reset {
ctx.dispatch(AppCommand::ResetBank { bank });
}
ctx.dispatch(AppCommand::CloseModal);
}
_ => {}
}
}
Modal::SaveAs(path) => match key.code {
KeyCode::Enter => {
let save_path = PathBuf::from(path.as_str());
@@ -400,12 +464,14 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
use crate::state::PatternsColumn;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Left => ctx.dispatch(AppCommand::PatternsCursorLeft),
KeyCode::Right => ctx.dispatch(AppCommand::PatternsCursorRight),
KeyCode::Up => ctx.dispatch(AppCommand::PatternsCursorUp),
KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown),
KeyCode::Esc | KeyCode::Backspace => ctx.dispatch(AppCommand::PatternsBack),
KeyCode::Esc => ctx.dispatch(AppCommand::PatternsBack),
KeyCode::Enter => ctx.dispatch(AppCommand::PatternsEnter),
KeyCode::Char(' ') => ctx.dispatch(AppCommand::PatternsTogglePlay),
KeyCode::Char('q') => {
@@ -413,6 +479,49 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
selected: false,
}));
}
KeyCode::Char('c') if ctrl => {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
ctx.dispatch(AppCommand::CopyBank { bank });
}
PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::CopyPattern { bank, pattern });
}
}
}
KeyCode::Char('v') if ctrl => {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
ctx.dispatch(AppCommand::PasteBank { bank });
}
PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::PastePattern { bank, pattern });
}
}
}
KeyCode::Delete | KeyCode::Backspace => {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetBank {
bank,
selected: false,
}));
}
PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetPattern {
bank,
pattern,
selected: false,
}));
}
}
}
KeyCode::Char('r') => {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {

View File

@@ -25,17 +25,37 @@ pub type Variables = Arc<Mutex<HashMap<String, Value>>>;
pub type Rng = Arc<Mutex<StdRng>>;
#[derive(Clone, Debug)]
pub(crate) enum Value {
pub enum Value {
Int(i64),
Float(f64),
Str(String),
Cmd(Vec<(String, String)>),
Param(String, String),
Marker,
}
#[derive(Clone, Debug, Default)]
struct CmdRegister {
sound: Option<String>,
params: Vec<(String, String)>,
}
impl CmdRegister {
fn set_sound(&mut self, name: String) {
self.sound = Some(name);
}
fn set_param(&mut self, key: String, value: String) {
self.params.push((key, value));
}
fn take(&mut self) -> Option<(String, Vec<(String, String)>)> {
let sound = self.sound.take()?;
let params = std::mem::take(&mut self.params);
Some((sound, params))
}
}
impl Value {
fn as_float(&self) -> Result<f64, String> {
pub fn as_float(&self) -> Result<f64, String> {
match self {
Value::Float(f) => Ok(*f),
Value::Int(i) => Ok(*i as f64),
@@ -63,8 +83,6 @@ impl Value {
Value::Int(i) => *i != 0,
Value::Float(f) => *f != 0.0,
Value::Str(s) => !s.is_empty(),
Value::Cmd(_) => true,
Value::Param(_, _) => true,
Value::Marker => false,
}
}
@@ -73,16 +91,12 @@ impl Value {
matches!(self, Value::Marker)
}
fn is_param(&self) -> bool {
matches!(self, Value::Param(_, _))
}
fn to_param_string(&self) -> String {
match self {
Value::Int(i) => i.to_string(),
Value::Float(f) => f.to_string(),
Value::Str(s) => s.clone(),
Value::Cmd(_) | Value::Param(_, _) | Value::Marker => String::new(),
Value::Marker => String::new(),
}
}
}
@@ -132,9 +146,21 @@ enum Op {
Choose,
Chance,
Maybe,
Wait,
ListStart,
ListEnd,
At,
Window,
Pop,
Subdivide,
SetTempo,
Each,
}
#[derive(Clone, Debug)]
struct TimeContext {
start: f64,
duration: f64,
subdivisions: Option<Vec<(f64, f64)>>,
}
#[derive(Clone, Debug)]
@@ -386,7 +412,12 @@ fn compile(tokens: &[Token]) -> Result<Vec<Op>, String> {
ops.push(Op::PushFloat(0.9));
ops.push(Op::Maybe);
}
"wait" => ops.push(Op::Wait),
"at" => ops.push(Op::At),
"window" => ops.push(Op::Window),
"pop" => ops.push(Op::Pop),
"div" => ops.push(Op::Subdivide),
"each" => ops.push(Op::Each),
"tempo!" => ops.push(Op::SetTempo),
"[" => ops.push(Op::ListStart),
"]" => ops.push(Op::ListEnd),
"step" => ops.push(Op::GetContext("step".into())),
@@ -397,7 +428,6 @@ fn compile(tokens: &[Token]) -> Result<Vec<Op>, String> {
"phase" => ops.push(Op::GetContext("phase".into())),
"slot" => ops.push(Op::GetContext("slot".into())),
"runs" => ops.push(Op::GetContext("runs".into())),
"speed" => ops.push(Op::GetContext("speed".into())),
"stepdur" => ops.push(Op::GetContext("stepdur".into())),
"if" => {
let (then_ops, else_ops, consumed) = compile_if(&tokens[i + 1..])?;
@@ -487,7 +517,12 @@ impl Forth {
fn execute(&self, ops: &[Op], ctx: &StepContext) -> Result<Vec<String>, String> {
let mut stack: Vec<Value> = Vec::new();
let mut outputs: Vec<String> = Vec::new();
let mut time_offset: f64 = 0.0;
let mut time_stack: Vec<TimeContext> = vec![TimeContext {
start: 0.0,
duration: ctx.step_duration(),
subdivisions: None,
}];
let mut cmd = CmdRegister::default();
let mut pc = 0;
while pc < ops.len() {
@@ -605,32 +640,21 @@ impl Forth {
Op::NewCmd => {
let name = stack.pop().ok_or("stack underflow")?;
let name = name.as_str()?;
stack.push(Value::Cmd(vec![("sound".into(), name.into())]));
cmd.set_sound(name.to_string());
}
Op::SetParam(param) => {
let val = stack.pop().ok_or("stack underflow")?;
stack.push(Value::Param(param.clone(), val.to_param_string()));
cmd.set_param(param.clone(), val.to_param_string());
}
Op::Emit => {
let mut params = Vec::new();
while let Some(v) = stack.last() {
if v.is_param() {
if let Value::Param(k, v) = stack.pop().unwrap() {
params.push((k, v));
let (sound, mut params) = cmd.take().ok_or("no sound set")?;
let mut pairs = vec![("sound".into(), sound)];
pairs.append(&mut params);
let time_ctx = time_stack.last().ok_or("time stack underflow")?;
if time_ctx.start > 0.0 {
pairs.push(("delta".into(), time_ctx.start.to_string()));
}
} else {
break;
}
}
params.reverse();
let cmd = stack.pop().ok_or("stack underflow")?;
if let Value::Cmd(mut pairs) = cmd {
pairs.extend(params);
if time_offset > 0.0 {
pairs.push(("delta".into(), time_offset.to_string()));
}
let has_dur = pairs.iter().any(|(k, _)| k == "dur");
if !has_dur {
if !pairs.iter().any(|(k, _)| k == "dur") {
pairs.push(("dur".into(), ctx.step_duration().to_string()));
}
let stepdur = ctx.step_duration();
@@ -641,9 +665,6 @@ impl Forth {
pairs.push(("delaytime".into(), stepdur.to_string()));
}
outputs.push(format_cmd(&pairs));
} else {
return Err("expected command".into());
}
}
Op::Get => {
@@ -729,20 +750,100 @@ impl Forth {
}
Op::Maybe => {
let prob = stack.pop().ok_or("stack underflow")?.as_float()?;
let param = stack.pop().ok_or("stack underflow")?;
if !param.is_param() {
return Err("? requires a param".into());
return Err("? is not yet implemented with the new param model".into());
}
let val: f64 = self.rng.lock().unwrap().gen();
if val < prob {
stack.push(param);
Op::At => {
let pos = stack.pop().ok_or("stack underflow")?.as_float()?;
let (sound, mut params) = cmd.take().ok_or("no sound set")?;
let mut pairs = vec![("sound".into(), sound)];
pairs.append(&mut params);
let time_ctx = time_stack.last().ok_or("time stack underflow")?;
let absolute_time = time_ctx.start + time_ctx.duration * pos;
if absolute_time > 0.0 {
pairs.push(("delta".into(), absolute_time.to_string()));
}
if !pairs.iter().any(|(k, _)| k == "dur") {
pairs.push(("dur".into(), ctx.step_duration().to_string()));
}
let stepdur = ctx.step_duration();
if let Some(idx) = pairs.iter().position(|(k, _)| k == "delaytime") {
let ratio: f64 = pairs[idx].1.parse().unwrap_or(1.0);
pairs[idx].1 = (ratio * stepdur).to_string();
} else {
pairs.push(("delaytime".into(), stepdur.to_string()));
}
outputs.push(format_cmd(&pairs));
}
Op::Window => {
let end = stack.pop().ok_or("stack underflow")?.as_float()?;
let start_pos = stack.pop().ok_or("stack underflow")?.as_float()?;
let parent = time_stack.last().ok_or("time stack underflow")?;
let new_start = parent.start + parent.duration * start_pos;
let new_duration = parent.duration * (end - start_pos);
time_stack.push(TimeContext {
start: new_start,
duration: new_duration,
subdivisions: None,
});
}
Op::Pop => {
if time_stack.len() <= 1 {
return Err("cannot pop root time context".into());
}
time_stack.pop();
}
Op::Subdivide => {
let n = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
if n == 0 {
return Err("subdivide count must be > 0".into());
}
let time_ctx = time_stack.last_mut().ok_or("time stack underflow")?;
let sub_duration = time_ctx.duration / n as f64;
let mut subs = Vec::with_capacity(n);
for i in 0..n {
subs.push((time_ctx.start + sub_duration * i as f64, sub_duration));
}
time_ctx.subdivisions = Some(subs);
}
Op::Each => {
let (sound, params) = cmd.take().ok_or("no sound set")?;
let time_ctx = time_stack.last().ok_or("time stack underflow")?;
let subs = time_ctx
.subdivisions
.as_ref()
.ok_or("each requires subdivide first")?;
for (sub_start, _sub_dur) in subs {
let mut pairs = vec![("sound".into(), sound.clone())];
pairs.extend(params.iter().cloned());
if *sub_start > 0.0 {
pairs.push(("delta".into(), sub_start.to_string()));
}
if !pairs.iter().any(|(k, _)| k == "dur") {
pairs.push(("dur".into(), ctx.step_duration().to_string()));
}
let stepdur = ctx.step_duration();
if let Some(idx) = pairs.iter().position(|(k, _)| k == "delaytime") {
let ratio: f64 = pairs[idx].1.parse().unwrap_or(1.0);
pairs[idx].1 = (ratio * stepdur).to_string();
} else {
pairs.push(("delaytime".into(), stepdur.to_string()));
}
outputs.push(format_cmd(&pairs));
}
}
Op::Wait => {
let duration = stack.pop().ok_or("stack underflow")?.as_float()?;
time_offset += duration;
Op::SetTempo => {
let tempo = stack.pop().ok_or("stack underflow")?.as_float()?;
let clamped = tempo.clamp(20.0, 300.0);
self.vars
.lock()
.unwrap()
.insert("__tempo__".to_string(), Value::Float(clamped));
}
Op::ListStart => {
@@ -770,7 +871,11 @@ impl Forth {
}
if outputs.is_empty() {
if let Some(Value::Cmd(pairs)) = stack.pop() {
if let Some((sound, params)) = cmd.take() {
let mut pairs = vec![("sound".into(), sound)];
pairs.extend(params);
pairs.push(("dur".into(), ctx.step_duration().to_string()));
pairs.push(("delaytime".into(), ctx.step_duration().to_string()));
outputs.push(format_cmd(&pairs));
}
}

View File

@@ -12,6 +12,15 @@ pub enum Modal {
step: usize,
selected: bool,
},
ConfirmResetPattern {
bank: usize,
pattern: usize,
selected: bool,
},
ConfirmResetBank {
bank: usize,
selected: bool,
},
SaveAs(String),
LoadFrom(String),
RenameBank {

View File

@@ -1,7 +1,7 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::app::App;

View File

@@ -1,6 +1,7 @@
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use crate::app::App;
@@ -8,28 +9,34 @@ use crate::engine::SequencerSnapshot;
use crate::state::PatternsColumn;
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
let [banks_area, patterns_area] =
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area);
let [banks_area, gap, patterns_area] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(area);
render_banks(frame, app, snapshot, banks_area);
// gap is just empty space
let _ = gap;
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 {
let [title_area, inner] =
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area);
let title_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(border_color))
.title("Banks");
let inner = block.inner(area);
frame.render_widget(block, area);
let title = Paragraph::new("Banks")
.style(Style::new().fg(title_color))
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(title, title_area);
let banks_with_playback: Vec<usize> = snapshot
.slot_data
@@ -48,14 +55,26 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
})
.collect();
let rows: Vec<Constraint> = (0..16).map(|_| Constraint::Length(1)).collect();
let row_areas = Layout::vertical(rows).split(inner);
let row_height = (inner.height / 16).max(1);
let total_needed = row_height * 16;
let top_padding = if inner.height > total_needed {
(inner.height - total_needed) / 2
} else {
0
};
for idx in 0..16 {
if idx >= row_areas.len() {
let y = inner.y + top_padding + (idx as u16) * row_height;
if y >= inner.y + inner.height {
break;
}
let row_area = row_areas[idx];
let row_area = Rect {
x: inner.x,
y,
width: inner.width,
height: row_height.min(inner.y + inner.height - y),
};
let is_cursor = is_focused && idx == app.patterns_nav.bank_cursor;
let is_selected = idx == app.patterns_nav.bank_cursor;
@@ -89,14 +108,35 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
style
};
// Fill the entire row with background color
let bg_block = Block::default().style(Style::new().bg(bg));
frame.render_widget(bg_block, row_area);
let text_y = if row_height > 1 {
row_area.y + (row_height - 1) / 2
} else {
row_area.y
};
let text_area = Rect {
x: row_area.x,
y: text_y,
width: row_area.width,
height: 1,
};
let para = Paragraph::new(label).style(style);
frame.render_widget(para, row_area);
frame.render_widget(para, text_area);
}
}
fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
use crate::model::PatternSpeed;
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Patterns);
let border_color = if is_focused {
let [title_area, inner] =
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area);
let title_color = if is_focused {
Color::Rgb(100, 160, 180)
} else {
Color::Rgb(70, 75, 85)
@@ -104,18 +144,14 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
let bank = app.patterns_nav.bank_cursor;
let bank_name = app.project_state.project.banks[bank].name.as_deref();
let title = match bank_name {
let title_text = match bank_name {
Some(name) => format!("Patterns ({name})"),
None => format!("Patterns (Bank {:02})", bank + 1),
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(border_color))
.title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
let title = Paragraph::new(title_text)
.style(Style::new().fg(title_color))
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(title, title_area);
let playing_patterns: Vec<usize> = snapshot
.slot_data
@@ -159,14 +195,26 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
None
};
let rows: Vec<Constraint> = (0..16).map(|_| Constraint::Length(1)).collect();
let row_areas = Layout::vertical(rows).split(inner);
let row_height = (inner.height / 16).max(1);
let total_needed = row_height * 16;
let top_padding = if inner.height > total_needed {
(inner.height - total_needed) / 2
} else {
0
};
for idx in 0..16 {
if idx >= row_areas.len() {
let y = inner.y + top_padding + (idx as u16) * row_height;
if y >= inner.y + inner.height {
break;
}
let row_area = row_areas[idx];
let row_area = Rect {
x: inner.x,
y,
width: inner.width,
height: row_height.min(inner.y + inner.height - y),
};
let is_cursor = is_focused && idx == app.patterns_nav.pattern_cursor;
let is_selected = idx == app.patterns_nav.pattern_cursor;
@@ -185,24 +233,70 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
(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() {
let pattern = &app.project_state.project.banks[bank].patterns[idx];
let name = pattern.name.as_deref().unwrap_or("");
let length = pattern.length;
let speed = pattern.speed;
let base_style = Style::new().bg(bg).fg(fg);
let bold_style = base_style.add_modifier(Modifier::BOLD);
// Fill the entire row with background color
let bg_block = Block::default().style(Style::new().bg(bg));
frame.render_widget(bg_block, row_area);
let text_y = if row_height > 1 {
row_area.y + (row_height - 1) / 2
} else {
row_area.y
};
// Split row into columns: [index+name] [length] [speed]
let speed_width: u16 = 14; // "Speed: 1/4x "
let length_width: u16 = 13; // "Length: 16 "
let name_width = row_area
.width
.saturating_sub(speed_width + length_width + 2);
let [name_area, length_area, speed_area] = Layout::horizontal([
Constraint::Length(name_width),
Constraint::Length(length_width),
Constraint::Length(speed_width),
])
.areas(Rect {
x: row_area.x,
y: text_y,
width: row_area.width,
height: 1,
});
// Column 1: prefix + index + name (left-aligned)
let name_text = 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)
let name_style = if is_playing || is_queued_play {
bold_style
} else {
style
base_style
};
frame.render_widget(Paragraph::new(name_text).style(name_style), name_area);
let para = Paragraph::new(label).style(style);
frame.render_widget(para, row_area);
// Column 2: length
let length_line = Line::from(vec![
Span::styled("Length: ", bold_style),
Span::styled(format!("{}", length), base_style),
]);
frame.render_widget(Paragraph::new(length_line), length_area);
// Column 3: speed (only if non-default)
if speed != PatternSpeed::Normal {
let speed_line = Line::from(vec![
Span::styled("Speed: ", bold_style),
Span::styled(speed.label(), base_style),
]);
frame.render_widget(Paragraph::new(speed_line), speed_area);
}
}
}

View File

@@ -28,8 +28,9 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq
height: term.height.saturating_sub(2),
};
let [header_area, body_area, footer_area] = Layout::vertical([
Constraint::Length(2),
let [header_area, _padding, body_area, footer_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(3),
])
@@ -49,94 +50,94 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq
}
fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let [top_row, bottom_row] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
use crate::model::PatternSpeed;
let pattern = app
.project_state
.project
.pattern_at(app.editor_ctx.bank, app.editor_ctx.pattern);
let bank = &app.project_state.project.banks[app.editor_ctx.bank];
let pattern = &bank.patterns[app.editor_ctx.pattern];
let play_symbol = if app.playback.playing { "" } else { "" };
let play_color = if app.playback.playing {
Color::Green
} else {
Color::Red
};
let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0);
let cpu_color = if cpu_pct > 80.0 {
Color::Red
} else if cpu_pct > 50.0 {
Color::Yellow
} else {
Color::Green
};
render_header_row(
frame,
top_row,
Line::from(vec![Span::styled(
format!(
"B{:02}:P{:02}",
app.editor_ctx.bank + 1,
app.editor_ctx.pattern + 1
),
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::styled(play_symbol, Style::new().fg(play_color)),
Span::raw(" "),
Span::styled(
format!("{:.1} BPM", link.tempo()),
Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(format!("CPU {:3.0}%", cpu_pct), Style::new().fg(cpu_color)),
Span::raw(" "),
Span::styled(
format!("V:{}", app.metrics.active_voices),
Style::new().fg(Color::Cyan),
),
]),
);
render_header_row(
frame,
bottom_row,
Line::from(vec![
Span::styled(
format!("Len {:02}", pattern.length),
Style::new().fg(Color::Rgb(180, 140, 90)),
),
Span::raw(" "),
Span::styled(
format!("Spd {}", pattern.speed.label()),
Style::new().fg(Color::Rgb(180, 140, 90)),
),
]),
Line::from(vec![]),
Line::from(vec![]),
);
}
fn render_header_row(frame: &mut Frame, area: Rect, left: Line, center: Line, right: Line) {
let [left_area, center_area, right_area] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Fill(1),
// Layout: [Transport] [Tempo] [Bank] [Pattern] [Stats]
let [transport_area, tempo_area, bank_area, pattern_area, stats_area] = Layout::horizontal([
Constraint::Min(12),
Constraint::Min(14),
Constraint::Fill(1),
Constraint::Fill(2),
Constraint::Min(20),
])
.areas(area);
frame.render_widget(Paragraph::new(left).alignment(Alignment::Left), left_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(center).alignment(Alignment::Center),
center_area,
Paragraph::new(transport_text)
.style(transport_style)
.alignment(Alignment::Center),
transport_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(right).alignment(Alignment::Right),
right_area,
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)
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 pattern_text = format!(
" {} · {} steps{} ",
pattern_name, pattern.length, speed_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 {:.0}% V:{} L:{} ", cpu_pct, voices, 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,
);
}
@@ -242,6 +243,20 @@ fn render_modal(frame: &mut Frame, app: &App, term: Rect) {
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)

View File

@@ -3,6 +3,7 @@ use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::widgets::Widget;
#[allow(dead_code)]
pub enum Orientation {
Horizontal,
Vertical,