Continue work on seq
This commit is contained in:
@@ -243,6 +243,12 @@ impl App {
|
||||
return;
|
||||
}
|
||||
|
||||
let speed = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at(bank, pattern)
|
||||
.speed
|
||||
.multiplier();
|
||||
let ctx = StepContext {
|
||||
step: step_idx,
|
||||
beat: link.beat(),
|
||||
@@ -252,6 +258,7 @@ impl App {
|
||||
phase: link.phase(),
|
||||
slot: 0,
|
||||
runs: 0,
|
||||
speed,
|
||||
};
|
||||
|
||||
match self.script_engine.evaluate(&script, &ctx) {
|
||||
@@ -309,6 +316,12 @@ impl App {
|
||||
continue;
|
||||
}
|
||||
|
||||
let speed = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at(bank, pattern)
|
||||
.speed
|
||||
.multiplier();
|
||||
let ctx = StepContext {
|
||||
step: step_idx,
|
||||
beat: 0.0,
|
||||
@@ -318,6 +331,7 @@ impl App {
|
||||
phase: 0.0,
|
||||
slot: 0,
|
||||
runs: 0,
|
||||
speed,
|
||||
};
|
||||
|
||||
if let Ok(cmds) = self.script_engine.evaluate(&script, &ctx) {
|
||||
@@ -435,9 +449,10 @@ impl App {
|
||||
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.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) {
|
||||
Ok(()) => {
|
||||
self.ui.set_status(format!("Saved: {}", path.display()));
|
||||
@@ -452,11 +467,13 @@ impl App {
|
||||
pub fn load(&mut self, path: PathBuf, link: &LinkState) {
|
||||
match model::load(&path) {
|
||||
Ok(project) => {
|
||||
let tempo = project.tempo;
|
||||
self.project_state.project = project;
|
||||
self.editor_ctx.step = 0;
|
||||
self.load_step_to_editor();
|
||||
self.compile_all_steps(link);
|
||||
self.mark_all_patterns_dirty();
|
||||
link.set_tempo(tempo);
|
||||
self.ui.set_status(format!("Loaded: {}", path.display()));
|
||||
self.project_state.file_path = Some(path);
|
||||
}
|
||||
@@ -726,7 +743,7 @@ impl App {
|
||||
} => {
|
||||
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),
|
||||
|
||||
// UI
|
||||
|
||||
@@ -398,6 +398,7 @@ fn sequencer_loop(
|
||||
phase: beat % quantum,
|
||||
slot: slot_idx,
|
||||
runs,
|
||||
speed: speed_mult,
|
||||
};
|
||||
if let Some(script) = resolved_script {
|
||||
if let Ok(cmds) = script_engine.evaluate(script, &ctx) {
|
||||
|
||||
@@ -231,6 +231,26 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
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 {
|
||||
KeyCode::Enter => {
|
||||
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('-') => 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::LengthIncrease),
|
||||
KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease),
|
||||
|
||||
@@ -14,6 +14,12 @@ struct ProjectFile {
|
||||
banks: Vec<Bank>,
|
||||
#[serde(default)]
|
||||
sample_paths: Vec<PathBuf>,
|
||||
#[serde(default = "default_tempo")]
|
||||
tempo: f64,
|
||||
}
|
||||
|
||||
fn default_tempo() -> f64 {
|
||||
120.0
|
||||
}
|
||||
|
||||
impl From<&Project> for ProjectFile {
|
||||
@@ -22,6 +28,7 @@ impl From<&Project> for ProjectFile {
|
||||
version: VERSION,
|
||||
banks: project.banks.clone(),
|
||||
sample_paths: project.sample_paths.clone(),
|
||||
tempo: project.tempo,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +38,7 @@ impl From<ProjectFile> for Project {
|
||||
Self {
|
||||
banks: file.banks,
|
||||
sample_paths: file.sample_paths,
|
||||
tempo: file.tempo,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ pub struct StepContext {
|
||||
pub phase: f64,
|
||||
pub slot: 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>>>;
|
||||
@@ -390,6 +397,8 @@ 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..])?;
|
||||
i += consumed;
|
||||
@@ -620,6 +629,17 @@ impl Forth {
|
||||
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 {
|
||||
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));
|
||||
} else {
|
||||
return Err("expected command".into());
|
||||
@@ -650,6 +670,8 @@ impl Forth {
|
||||
"phase" => Value::Float(ctx.phase),
|
||||
"slot" => Value::Int(ctx.slot as i64),
|
||||
"runs" => Value::Int(ctx.runs as i64),
|
||||
"speed" => Value::Float(ctx.speed),
|
||||
"stepdur" => Value::Float(ctx.step_duration()),
|
||||
_ => Value::Int(0),
|
||||
};
|
||||
stack.push(val);
|
||||
|
||||
@@ -181,6 +181,12 @@ pub struct Project {
|
||||
pub banks: Vec<Bank>,
|
||||
#[serde(default)]
|
||||
pub sample_paths: Vec<PathBuf>,
|
||||
#[serde(default = "default_tempo")]
|
||||
pub tempo: f64,
|
||||
}
|
||||
|
||||
fn default_tempo() -> f64 {
|
||||
120.0
|
||||
}
|
||||
|
||||
impl Default for Project {
|
||||
@@ -188,6 +194,7 @@ impl Default for Project {
|
||||
Self {
|
||||
banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(),
|
||||
sample_paths: Vec::new(),
|
||||
tempo: default_tempo(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ pub enum Modal {
|
||||
field: PatternField,
|
||||
input: String,
|
||||
},
|
||||
SetTempo(String),
|
||||
AddSamplePath(String),
|
||||
Editor,
|
||||
}
|
||||
|
||||
@@ -10,38 +10,32 @@ use crate::views::highlight::highlight_line;
|
||||
use crate::widgets::{Orientation, Scope, VuMeter};
|
||||
|
||||
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::Length(10),
|
||||
Constraint::Length(10),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(8),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
let [sequencer_area, preview_area] =
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]).areas(main_area);
|
||||
let [scope_area, sequencer_area, preview_area] = Layout::vertical([
|
||||
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_step_preview(frame, app, preview_area);
|
||||
render_scope(frame, app, scope_area);
|
||||
render_vu_meter(frame, app, vu_area);
|
||||
}
|
||||
|
||||
fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||
let border_style = Style::new().fg(Color::Rgb(100, 160, 180));
|
||||
|
||||
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 {
|
||||
if area.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);
|
||||
frame.render_widget(msg, area);
|
||||
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 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)
|
||||
.map(|i| {
|
||||
@@ -67,7 +61,7 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let rows = Layout::vertical(row_constraints).split(inner);
|
||||
let rows = Layout::vertical(row_constraints).split(area);
|
||||
|
||||
for row_idx in 0..num_rows {
|
||||
let row_area = rows[row_idx * 2];
|
||||
@@ -151,29 +145,15 @@ fn render_tile(
|
||||
}
|
||||
|
||||
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)
|
||||
.orientation(Orientation::Vertical)
|
||||
.orientation(Orientation::Horizontal)
|
||||
.color(Color::Green);
|
||||
frame.render_widget(scope, inner);
|
||||
frame.render_widget(scope, area);
|
||||
}
|
||||
|
||||
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);
|
||||
frame.render_widget(vu, inner);
|
||||
frame.render_widget(vu, area);
|
||||
}
|
||||
|
||||
fn render_step_preview(frame: &mut Frame, app: &App, area: Rect) {
|
||||
|
||||
@@ -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::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
@@ -21,12 +21,19 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq
|
||||
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([
|
||||
Constraint::Length(2),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.areas(term);
|
||||
.areas(padded);
|
||||
|
||||
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] =
|
||||
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_color = if app.playback.playing {
|
||||
Color::Green
|
||||
@@ -61,137 +73,162 @@ fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
Color::Green
|
||||
};
|
||||
|
||||
let pattern = app
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at(app.editor_ctx.bank, app.editor_ctx.pattern);
|
||||
|
||||
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(
|
||||
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),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("L:{:02}", pattern.length),
|
||||
Style::new().fg(Color::Rgb(180, 140, 90)),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("S:{}", pattern.speed.label()),
|
||||
Style::new().fg(Color::Rgb(180, 140, 90)),
|
||||
),
|
||||
];
|
||||
)]),
|
||||
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),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
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) {
|
||||
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] ",
|
||||
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,
|
||||
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 {
|
||||
match app.page {
|
||||
Page::Main => Line::from(vec![
|
||||
Span::styled(
|
||||
page_indicator,
|
||||
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
|
||||
),
|
||||
Span::styled("←→↑↓", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":nav "),
|
||||
Span::styled("t", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":toggle "),
|
||||
Span::styled("Enter", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":edit "),
|
||||
Span::styled("<>", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":len "),
|
||||
Span::styled("[]", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":spd "),
|
||||
Span::styled("s/l", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":save/load"),
|
||||
]),
|
||||
Page::Patterns => Line::from(vec![
|
||||
Span::styled(
|
||||
page_indicator,
|
||||
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
|
||||
),
|
||||
Span::styled("←→↑↓", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":nav "),
|
||||
Span::styled("Enter", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":select "),
|
||||
Span::styled("Space", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":play "),
|
||||
Span::styled("Esc", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":back"),
|
||||
]),
|
||||
Page::Audio => Line::from(vec![
|
||||
Span::styled(
|
||||
page_indicator,
|
||||
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
|
||||
),
|
||||
Span::styled("q", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":quit "),
|
||||
Span::styled("h", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":hush "),
|
||||
Span::styled("p", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":panic "),
|
||||
Span::styled("r", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":reset "),
|
||||
Span::styled("t", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":test "),
|
||||
Span::styled("C-←→", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":page"),
|
||||
]),
|
||||
Page::Doc => Line::from(vec![
|
||||
Span::styled(
|
||||
page_indicator,
|
||||
Style::new().fg(Color::White).add_modifier(Modifier::DIM),
|
||||
),
|
||||
Span::styled("j/k", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":topic "),
|
||||
Span::styled("PgUp/Dn", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":scroll "),
|
||||
Span::styled("C-←→", Style::new().fg(Color::Yellow)),
|
||||
Span::raw(":page"),
|
||||
]),
|
||||
let bindings: Vec<(&str, &str)> = match app.page {
|
||||
Page::Main => vec![
|
||||
("←→↑↓", "nav"),
|
||||
("t", "toggle"),
|
||||
("Enter", "edit"),
|
||||
("<>", "len"),
|
||||
("[]", "spd"),
|
||||
("s/l", "save/load"),
|
||||
],
|
||||
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::default().borders(Borders::ALL));
|
||||
let footer = Paragraph::new(content).block(block);
|
||||
frame.render_widget(footer, area);
|
||||
}
|
||||
|
||||
@@ -247,6 +284,13 @@ fn render_modal(frame: &mut Frame, app: &App, term: Rect) {
|
||||
.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")
|
||||
|
||||
@@ -52,15 +52,50 @@ impl Widget for Scope<'_> {
|
||||
}
|
||||
|
||||
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 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];
|
||||
let mut patterns = vec![0u8; width * height];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user