Continue work on seq

This commit is contained in:
2026-01-20 15:52:17 +01:00
parent ebb82b6f7d
commit 1ba74f850c
11 changed files with 43132 additions and 155 deletions

View File

@@ -243,6 +243,12 @@ impl App {
return; return;
} }
let speed = self
.project_state
.project
.pattern_at(bank, pattern)
.speed
.multiplier();
let ctx = StepContext { let ctx = StepContext {
step: step_idx, step: step_idx,
beat: link.beat(), beat: link.beat(),
@@ -252,6 +258,7 @@ impl App {
phase: link.phase(), phase: link.phase(),
slot: 0, slot: 0,
runs: 0, runs: 0,
speed,
}; };
match self.script_engine.evaluate(&script, &ctx) { match self.script_engine.evaluate(&script, &ctx) {
@@ -309,6 +316,12 @@ impl App {
continue; continue;
} }
let speed = self
.project_state
.project
.pattern_at(bank, pattern)
.speed
.multiplier();
let ctx = StepContext { let ctx = StepContext {
step: step_idx, step: step_idx,
beat: 0.0, beat: 0.0,
@@ -318,6 +331,7 @@ impl App {
phase: 0.0, phase: 0.0,
slot: 0, slot: 0,
runs: 0, runs: 0,
speed,
}; };
if let Ok(cmds) = self.script_engine.evaluate(&script, &ctx) { if let Ok(cmds) = self.script_engine.evaluate(&script, &ctx) {
@@ -435,9 +449,10 @@ impl App {
self.load_step_to_editor(); self.load_step_to_editor();
} }
pub fn save(&mut self, path: PathBuf) { pub fn save(&mut self, path: PathBuf, link: &LinkState) {
self.save_editor_to_step(); self.save_editor_to_step();
self.project_state.project.sample_paths = self.audio.config.sample_paths.clone(); self.project_state.project.sample_paths = self.audio.config.sample_paths.clone();
self.project_state.project.tempo = link.tempo();
match model::save(&self.project_state.project, &path) { match model::save(&self.project_state.project, &path) {
Ok(()) => { Ok(()) => {
self.ui.set_status(format!("Saved: {}", path.display())); self.ui.set_status(format!("Saved: {}", path.display()));
@@ -452,11 +467,13 @@ impl App {
pub fn load(&mut self, path: PathBuf, link: &LinkState) { pub fn load(&mut self, path: PathBuf, link: &LinkState) {
match model::load(&path) { match model::load(&path) {
Ok(project) => { Ok(project) => {
let tempo = project.tempo;
self.project_state.project = project; self.project_state.project = project;
self.editor_ctx.step = 0; self.editor_ctx.step = 0;
self.load_step_to_editor(); self.load_step_to_editor();
self.compile_all_steps(link); self.compile_all_steps(link);
self.mark_all_patterns_dirty(); self.mark_all_patterns_dirty();
link.set_tempo(tempo);
self.ui.set_status(format!("Loaded: {}", path.display())); self.ui.set_status(format!("Loaded: {}", path.display()));
self.project_state.file_path = Some(path); self.project_state.file_path = Some(path);
} }
@@ -726,7 +743,7 @@ impl App {
} => { } => {
self.project_state.project.banks[bank].patterns[pattern].name = name; self.project_state.project.banks[bank].patterns[pattern].name = name;
} }
AppCommand::Save(path) => self.save(path), AppCommand::Save(path) => self.save(path, link),
AppCommand::Load(path) => self.load(path, link), AppCommand::Load(path) => self.load(path, link),
// UI // UI

View File

@@ -398,6 +398,7 @@ fn sequencer_loop(
phase: beat % quantum, phase: beat % quantum,
slot: slot_idx, slot: slot_idx,
runs, runs,
speed: speed_mult,
}; };
if let Some(script) = resolved_script { if let Some(script) = resolved_script {
if let Ok(cmds) = script_engine.evaluate(script, &ctx) { if let Ok(cmds) = script_engine.evaluate(script, &ctx) {

View File

@@ -231,6 +231,26 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
KeyCode::Char(c) => input.push(c), KeyCode::Char(c) => input.push(c),
_ => {} _ => {}
}, },
Modal::SetTempo(input) => match key.code {
KeyCode::Enter => {
if let Ok(tempo) = input.parse::<f64>() {
let tempo = tempo.clamp(20.0, 300.0);
ctx.link.set_tempo(tempo);
ctx.dispatch(AppCommand::SetStatus(format!(
"Tempo set to {tempo:.1} BPM"
)));
} else {
ctx.dispatch(AppCommand::SetStatus("Invalid tempo".to_string()));
}
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Backspace => {
input.pop();
}
KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => input.push(c),
_ => {}
},
Modal::AddSamplePath(path) => match key.code { Modal::AddSamplePath(path) => match key.code {
KeyCode::Enter => { KeyCode::Enter => {
let sample_path = PathBuf::from(path.as_str()); let sample_path = PathBuf::from(path.as_str());
@@ -352,6 +372,10 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
} }
KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp), KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp),
KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown), KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown),
KeyCode::Char('T') => {
let current = format!("{:.1}", ctx.link.tempo());
ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current)));
}
KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease), KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease),
KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease), KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease),
KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease), KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease),

View File

@@ -14,6 +14,12 @@ struct ProjectFile {
banks: Vec<Bank>, banks: Vec<Bank>,
#[serde(default)] #[serde(default)]
sample_paths: Vec<PathBuf>, sample_paths: Vec<PathBuf>,
#[serde(default = "default_tempo")]
tempo: f64,
}
fn default_tempo() -> f64 {
120.0
} }
impl From<&Project> for ProjectFile { impl From<&Project> for ProjectFile {
@@ -22,6 +28,7 @@ impl From<&Project> for ProjectFile {
version: VERSION, version: VERSION,
banks: project.banks.clone(), banks: project.banks.clone(),
sample_paths: project.sample_paths.clone(), sample_paths: project.sample_paths.clone(),
tempo: project.tempo,
} }
} }
} }
@@ -31,6 +38,7 @@ impl From<ProjectFile> for Project {
Self { Self {
banks: file.banks, banks: file.banks,
sample_paths: file.sample_paths, sample_paths: file.sample_paths,
tempo: file.tempo,
} }
} }
} }

View File

@@ -12,6 +12,13 @@ pub struct StepContext {
pub phase: f64, pub phase: f64,
pub slot: usize, pub slot: usize,
pub runs: usize, pub runs: usize,
pub speed: f64,
}
impl StepContext {
pub fn step_duration(&self) -> f64 {
60.0 / self.tempo / 4.0 / self.speed
}
} }
pub type Variables = Arc<Mutex<HashMap<String, Value>>>; pub type Variables = Arc<Mutex<HashMap<String, Value>>>;
@@ -390,6 +397,8 @@ fn compile(tokens: &[Token]) -> Result<Vec<Op>, String> {
"phase" => ops.push(Op::GetContext("phase".into())), "phase" => ops.push(Op::GetContext("phase".into())),
"slot" => ops.push(Op::GetContext("slot".into())), "slot" => ops.push(Op::GetContext("slot".into())),
"runs" => ops.push(Op::GetContext("runs".into())), "runs" => ops.push(Op::GetContext("runs".into())),
"speed" => ops.push(Op::GetContext("speed".into())),
"stepdur" => ops.push(Op::GetContext("stepdur".into())),
"if" => { "if" => {
let (then_ops, else_ops, consumed) = compile_if(&tokens[i + 1..])?; let (then_ops, else_ops, consumed) = compile_if(&tokens[i + 1..])?;
i += consumed; i += consumed;
@@ -620,6 +629,17 @@ impl Forth {
if time_offset > 0.0 { if time_offset > 0.0 {
pairs.push(("delta".into(), time_offset.to_string())); pairs.push(("delta".into(), time_offset.to_string()));
} }
let has_dur = pairs.iter().any(|(k, _)| k == "dur");
if !has_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)); outputs.push(format_cmd(&pairs));
} else { } else {
return Err("expected command".into()); return Err("expected command".into());
@@ -650,6 +670,8 @@ impl Forth {
"phase" => Value::Float(ctx.phase), "phase" => Value::Float(ctx.phase),
"slot" => Value::Int(ctx.slot as i64), "slot" => Value::Int(ctx.slot as i64),
"runs" => Value::Int(ctx.runs as i64), "runs" => Value::Int(ctx.runs as i64),
"speed" => Value::Float(ctx.speed),
"stepdur" => Value::Float(ctx.step_duration()),
_ => Value::Int(0), _ => Value::Int(0),
}; };
stack.push(val); stack.push(val);

View File

@@ -181,6 +181,12 @@ pub struct Project {
pub banks: Vec<Bank>, pub banks: Vec<Bank>,
#[serde(default)] #[serde(default)]
pub sample_paths: Vec<PathBuf>, pub sample_paths: Vec<PathBuf>,
#[serde(default = "default_tempo")]
pub tempo: f64,
}
fn default_tempo() -> f64 {
120.0
} }
impl Default for Project { impl Default for Project {
@@ -188,6 +194,7 @@ impl Default for Project {
Self { Self {
banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(), banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(),
sample_paths: Vec::new(), sample_paths: Vec::new(),
tempo: default_tempo(),
} }
} }
} }

View File

@@ -27,6 +27,7 @@ pub enum Modal {
field: PatternField, field: PatternField,
input: String, input: String,
}, },
SetTempo(String),
AddSamplePath(String), AddSamplePath(String),
Editor, Editor,
} }

View File

@@ -10,38 +10,32 @@ use crate::views::highlight::highlight_line;
use crate::widgets::{Orientation, Scope, VuMeter}; use crate::widgets::{Orientation, Scope, VuMeter};
pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, area: Rect) { pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, area: Rect) {
let [main_area, scope_area, vu_area] = Layout::horizontal([ let [left_area, _spacer, vu_area] = Layout::horizontal([
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Length(10), Constraint::Length(2),
Constraint::Length(10), Constraint::Length(8),
]) ])
.areas(area); .areas(area);
let [sequencer_area, preview_area] = let [scope_area, sequencer_area, preview_area] = Layout::vertical([
Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]).areas(main_area); Constraint::Length(8),
Constraint::Fill(1),
Constraint::Length(2),
])
.areas(left_area);
render_scope(frame, app, scope_area);
render_sequencer(frame, app, snapshot, sequencer_area); render_sequencer(frame, app, snapshot, sequencer_area);
render_step_preview(frame, app, preview_area); render_step_preview(frame, app, preview_area);
render_scope(frame, app, scope_area);
render_vu_meter(frame, app, vu_area); render_vu_meter(frame, app, vu_area);
} }
fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
let border_style = Style::new().fg(Color::Rgb(100, 160, 180)); if area.width < 50 {
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title("Sequencer");
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width < 50 {
let msg = Paragraph::new("Terminal too narrow") let msg = Paragraph::new("Terminal too narrow")
.alignment(Alignment::Center) .alignment(Alignment::Center)
.style(Style::new().fg(Color::Rgb(120, 125, 135))); .style(Style::new().fg(Color::Rgb(120, 125, 135)));
frame.render_widget(msg, inner); frame.render_widget(msg, area);
return; return;
} }
@@ -56,7 +50,7 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot,
let steps_per_row = length.div_ceil(num_rows); let steps_per_row = length.div_ceil(num_rows);
let spacing = num_rows.saturating_sub(1) as u16; let spacing = num_rows.saturating_sub(1) as u16;
let row_height = inner.height.saturating_sub(spacing) / num_rows as u16; let row_height = area.height.saturating_sub(spacing) / num_rows as u16;
let row_constraints: Vec<Constraint> = (0..num_rows * 2 - 1) let row_constraints: Vec<Constraint> = (0..num_rows * 2 - 1)
.map(|i| { .map(|i| {
@@ -67,7 +61,7 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot,
} }
}) })
.collect(); .collect();
let rows = Layout::vertical(row_constraints).split(inner); let rows = Layout::vertical(row_constraints).split(area);
for row_idx in 0..num_rows { for row_idx in 0..num_rows {
let row_area = rows[row_idx * 2]; let row_area = rows[row_idx * 2];
@@ -151,29 +145,15 @@ fn render_tile(
} }
fn render_scope(frame: &mut Frame, app: &App, area: Rect) { fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Rgb(70, 75, 85)));
let inner = block.inner(area);
frame.render_widget(block, area);
let scope = Scope::new(&app.metrics.scope) let scope = Scope::new(&app.metrics.scope)
.orientation(Orientation::Vertical) .orientation(Orientation::Horizontal)
.color(Color::Green); .color(Color::Green);
frame.render_widget(scope, inner); frame.render_widget(scope, area);
} }
fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) { fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Rgb(70, 75, 85)));
let inner = block.inner(area);
frame.render_widget(block, area);
let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right); let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right);
frame.render_widget(vu, inner); frame.render_widget(vu, area);
} }
fn render_step_preview(frame: &mut Frame, app: &App, area: Rect) { fn render_step_preview(frame: &mut Frame, app: &App, area: Rect) {

View File

@@ -1,4 +1,4 @@
use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::widgets::{Block, Borders, Paragraph};
@@ -21,12 +21,19 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq
return; 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, body_area, footer_area] = Layout::vertical([ let [header_area, body_area, footer_area] = Layout::vertical([
Constraint::Length(2), Constraint::Length(2),
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Length(3), Constraint::Length(3),
]) ])
.areas(term); .areas(padded);
render_header(frame, app, link, header_area); render_header(frame, app, link, header_area);
@@ -45,6 +52,11 @@ fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let [top_row, bottom_row] = let [top_row, bottom_row] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area); Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
let pattern = app
.project_state
.project
.pattern_at(app.editor_ctx.bank, app.editor_ctx.pattern);
let play_symbol = if app.playback.playing { "" } else { "" }; let play_symbol = if app.playback.playing { "" } else { "" };
let play_color = if app.playback.playing { let play_color = if app.playback.playing {
Color::Green Color::Green
@@ -61,137 +73,162 @@ fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
Color::Green Color::Green
}; };
let pattern = app render_header_row(
.project_state frame,
.project top_row,
.pattern_at(app.editor_ctx.bank, app.editor_ctx.pattern); Line::from(vec![Span::styled(
let top_spans = 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),
),
Span::raw(" "),
Span::styled(format!("CPU {cpu_pct:3.0}%"), Style::new().fg(cpu_color)),
Span::raw(" "),
Span::styled(
format!("V:{}", app.metrics.active_voices),
Style::new().fg(Color::Cyan),
),
];
frame.render_widget(Paragraph::new(Line::from(top_spans)), top_row);
let bottom_spans = vec![
Span::styled(
format!( format!(
"B{:02}:P{:02}", "B{:02}:P{:02}",
app.editor_ctx.bank + 1, app.editor_ctx.bank + 1,
app.editor_ctx.pattern + 1 app.editor_ctx.pattern + 1
), ),
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD), Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
), )]),
Span::raw(" "), Line::from(vec![
Span::styled( Span::styled(play_symbol, Style::new().fg(play_color)),
format!("L:{:02}", pattern.length), Span::raw(" "),
Style::new().fg(Color::Rgb(180, 140, 90)), Span::styled(
), format!("{:.1} BPM", link.tempo()),
Span::raw(" "), Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD),
Span::styled( ),
format!("S:{}", pattern.speed.label()), ]),
Style::new().fg(Color::Rgb(180, 140, 90)), 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),
),
]),
);
frame.render_widget(Paragraph::new(Line::from(bottom_spans)), bottom_row); 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),
Constraint::Fill(1),
])
.areas(area);
frame.render_widget(Paragraph::new(left).alignment(Alignment::Left), left_area);
frame.render_widget(
Paragraph::new(center).alignment(Alignment::Center),
center_area,
);
frame.render_widget(
Paragraph::new(right).alignment(Alignment::Right),
right_area,
);
} }
fn render_footer(frame: &mut Frame, app: &App, area: Rect) { 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 { let page_indicator = match app.page {
Page::Main => "[MAIN] ", Page::Main => "[MAIN]",
Page::Patterns => "[PATTERNS] ", Page::Patterns => "[PATTERNS]",
Page::Audio => "[AUDIO] ", Page::Audio => "[AUDIO]",
Page::Doc => "[DOC] ", Page::Doc => "[DOC]",
}; };
let content = if let Some(ref msg) = app.ui.status_message { let content = if let Some(ref msg) = app.ui.status_message {
Line::from(vec![ Line::from(vec![
Span::styled( Span::styled(
page_indicator, page_indicator.to_string(),
Style::new().fg(Color::White).add_modifier(Modifier::DIM), Style::new().fg(Color::White).add_modifier(Modifier::DIM),
), ),
Span::raw(" "),
Span::styled(msg.clone(), Style::new().fg(Color::Yellow)), Span::styled(msg.clone(), Style::new().fg(Color::Yellow)),
]) ])
} else { } else {
match app.page { let bindings: Vec<(&str, &str)> = match app.page {
Page::Main => Line::from(vec![ Page::Main => vec![
Span::styled( ("←→↑↓", "nav"),
page_indicator, ("t", "toggle"),
Style::new().fg(Color::White).add_modifier(Modifier::DIM), ("Enter", "edit"),
), ("<>", "len"),
Span::styled("←→↑↓", Style::new().fg(Color::Yellow)), ("[]", "spd"),
Span::raw(":nav "), ("s/l", "save/load"),
Span::styled("t", Style::new().fg(Color::Yellow)), ],
Span::raw(":toggle "), Page::Patterns => vec![
Span::styled("Enter", Style::new().fg(Color::Yellow)), ("←→↑↓", "nav"),
Span::raw(":edit "), ("Enter", "select"),
Span::styled("<>", Style::new().fg(Color::Yellow)), ("Space", "play"),
Span::raw(":len "), ("Esc", "back"),
Span::styled("[]", Style::new().fg(Color::Yellow)), ],
Span::raw(":spd "), Page::Audio => vec![
Span::styled("s/l", Style::new().fg(Color::Yellow)), ("q", "quit"),
Span::raw(":save/load"), ("h", "hush"),
]), ("p", "panic"),
Page::Patterns => Line::from(vec![ ("r", "reset"),
Span::styled( ("t", "test"),
page_indicator, ("C-←→", "page"),
Style::new().fg(Color::White).add_modifier(Modifier::DIM), ],
), Page::Doc => vec![("j/k", "topic"), ("PgUp/Dn", "scroll"), ("C-←→", "page")],
Span::styled("←→↑↓", Style::new().fg(Color::Yellow)), };
Span::raw(":nav "),
Span::styled("Enter", Style::new().fg(Color::Yellow)), let page_width = page_indicator.chars().count();
Span::raw(":select "), let bindings_content_width: usize = bindings
Span::styled("Space", Style::new().fg(Color::Yellow)), .iter()
Span::raw(":play "), .map(|(k, a)| k.chars().count() + 1 + a.chars().count())
Span::styled("Esc", Style::new().fg(Color::Yellow)), .sum();
Span::raw(":back"),
]), let n = bindings.len();
Page::Audio => Line::from(vec![ let total_content = page_width + bindings_content_width;
Span::styled( let total_gaps = available_width.saturating_sub(total_content);
page_indicator, let gap_count = n + 1;
Style::new().fg(Color::White).add_modifier(Modifier::DIM), let base_gap = total_gaps / gap_count;
), let extra = total_gaps % gap_count;
Span::styled("q", Style::new().fg(Color::Yellow)),
Span::raw(":quit "), let mut spans = vec![
Span::styled("h", Style::new().fg(Color::Yellow)), Span::styled(
Span::raw(":hush "), page_indicator.to_string(),
Span::styled("p", Style::new().fg(Color::Yellow)), Style::new().fg(Color::White).add_modifier(Modifier::DIM),
Span::raw(":panic "), ),
Span::styled("r", Style::new().fg(Color::Yellow)), Span::raw(" ".repeat(base_gap + if extra > 0 { 1 } else { 0 })),
Span::raw(":reset "), ];
Span::styled("t", Style::new().fg(Color::Yellow)),
Span::raw(":test "), for (i, (key, action)) in bindings.into_iter().enumerate() {
Span::styled("C-←→", Style::new().fg(Color::Yellow)), spans.push(Span::styled(
Span::raw(":page"), key.to_string(),
]), Style::new().fg(Color::Yellow),
Page::Doc => Line::from(vec![ ));
Span::styled( spans.push(Span::styled(
page_indicator, format!(" {action}"),
Style::new().fg(Color::White).add_modifier(Modifier::DIM), Style::new().fg(Color::Rgb(120, 125, 135)),
), ));
Span::styled("j/k", Style::new().fg(Color::Yellow)),
Span::raw(":topic "), if i < n - 1 {
Span::styled("PgUp/Dn", Style::new().fg(Color::Yellow)), let gap = base_gap + if i + 1 < extra { 1 } else { 0 };
Span::raw(":scroll "), spans.push(Span::raw(" ".repeat(gap)));
Span::styled("C-←→", Style::new().fg(Color::Yellow)), }
Span::raw(":page"),
]),
} }
Line::from(spans)
}; };
let footer = Paragraph::new(content).block(Block::default().borders(Borders::ALL)); let footer = Paragraph::new(content).block(block);
frame.render_widget(footer, area); frame.render_widget(footer, area);
} }
@@ -247,6 +284,13 @@ fn render_modal(frame: &mut Frame, app: &App, term: Rect) {
.border_color(Color::Yellow) .border_color(Color::Yellow)
.render_centered(frame, term); .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) => { Modal::AddSamplePath(path) => {
TextInputModal::new("Add Sample Path", path) TextInputModal::new("Add Sample Path", path)
.hint("Enter directory path containing samples") .hint("Enter directory path containing samples")

View File

@@ -52,15 +52,50 @@ impl Widget for Scope<'_> {
} }
fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gain: f32) { fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gain: f32) {
const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; let width = area.width as usize;
let height = area.height as usize;
let fine_width = width * 2;
let fine_height = height * 4;
for x in 0..area.width { let mut patterns = vec![0u8; width * height];
let sample_idx = (x as usize * data.len()) / area.width as usize;
let sample = data.get(sample_idx).copied().unwrap_or(0.0) * gain;
let level = (sample.abs() * 8.0).min(7.0) as usize;
let ch = BLOCKS[level];
buf[(area.x + x, area.y)].set_char(ch).set_fg(color); for fine_x in 0..fine_width {
let sample_idx = (fine_x * data.len()) / fine_width;
let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * gain).clamp(-1.0, 1.0);
let fine_y = ((1.0 - sample) * 0.5 * (fine_height - 1) as f32).round() as usize;
let fine_y = fine_y.min(fine_height - 1);
let char_x = fine_x / 2;
let char_y = fine_y / 4;
let dot_x = fine_x % 2;
let dot_y = fine_y % 4;
let bit = match (dot_x, dot_y) {
(0, 0) => 0x01,
(0, 1) => 0x02,
(0, 2) => 0x04,
(0, 3) => 0x40,
(1, 0) => 0x08,
(1, 1) => 0x10,
(1, 2) => 0x20,
(1, 3) => 0x80,
_ => unreachable!(),
};
patterns[char_y * width + char_x] |= bit;
}
for cy in 0..height {
for cx in 0..width {
let pattern = patterns[cy * width + cx];
if pattern != 0 {
let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' ');
buf[(area.x + cx as u16, area.y + cy as u16)]
.set_char(ch)
.set_fg(color);
}
}
} }
} }

42838
seq/test Normal file

File diff suppressed because it is too large Load Diff