diff --git a/Cargo.lock b/Cargo.lock index 93e293c..edde421 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1259,7 +1259,7 @@ source = "git+https://github.com/sourcebox/mi-plaits-dsp-rs?rev=dc55bd55e73bd6f8 dependencies = [ "dyn-clone", "num-traits", - "spin 0.10.0", + "spin", ] [[package]] @@ -1410,15 +1410,6 @@ dependencies = [ "libc", ] -[[package]] -name = "no-std-compat" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" -dependencies = [ - "spin 0.5.2", -] - [[package]] name = "nom" version = "7.1.3" @@ -1943,7 +1934,6 @@ checksum = "1f9ef5dabe4c0b43d8f1187dc6beb67b53fe607fff7e30c5eb7f71b814b8c2c1" dependencies = [ "ahash", "bitflags 2.10.0", - "no-std-compat", "num-traits", "once_cell", "rhai_codegen", @@ -2097,7 +2087,6 @@ dependencies = [ "minimad", "rand 0.8.5", "ratatui", - "rhai", "rusty_link", "serde", "serde_json", @@ -2259,12 +2248,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "spin" version = "0.10.0" diff --git a/seq/Cargo.toml b/seq/Cargo.toml index 9ae01cc..d4d6b19 100644 --- a/seq/Cargo.toml +++ b/seq/Cargo.toml @@ -14,7 +14,7 @@ ratatui = "0.29" crossterm = "0.28" cpal = "0.15" clap = { version = "4", features = ["derive"] } -rhai = { version = "1.24", features = ["sync"] } + rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/seq/src/app.rs b/seq/src/app.rs index eb70e9d..5f10d3d 100644 --- a/seq/src/app.rs +++ b/seq/src/app.rs @@ -15,8 +15,8 @@ use crate::model::{self, Pattern, Rng, ScriptEngine, StepContext, Variables}; use crate::page::Page; use crate::services::pattern_editor; use crate::state::{ - AudioSettings, EditorContext, Focus, Metrics, Modal, PatternField, PatternsViewLevel, - PlaybackState, ProjectState, UiState, + AudioSettings, EditorContext, Focus, Metrics, Modal, PatternField, PatternsNav, PlaybackState, + ProjectState, UiState, }; use crate::views::doc_view; @@ -28,8 +28,7 @@ pub struct App { pub page: Page, pub editor_ctx: EditorContext, - pub patterns_view_level: PatternsViewLevel, - pub patterns_cursor: usize, + pub patterns_nav: PatternsNav, pub metrics: Metrics, pub sample_pool_mb: f32, @@ -55,8 +54,7 @@ impl App { page: Page::default(), editor_ctx: EditorContext::default(), - patterns_view_level: PatternsViewLevel::default(), - patterns_cursor: 0, + patterns_nav: PatternsNav::default(), metrics: Metrics::default(), sample_pool_mb: 0.0, @@ -253,17 +251,22 @@ impl App { tempo: link.tempo(), phase: link.phase(), slot: 0, + runs: 0, }; match self.script_engine.evaluate(&script, &ctx) { - Ok(cmd) => { + Ok(cmds) => { if let Some(step) = self .project_state .project .pattern_at_mut(bank, pattern) .step_mut(step_idx) { - step.command = Some(cmd); + step.command = if cmds.is_empty() { + None + } else { + Some(cmds.join("\n")) + }; } self.ui.flash("Script compiled", 150); } @@ -314,16 +317,21 @@ impl App { tempo: link.tempo(), phase: 0.0, slot: 0, + runs: 0, }; - if let Ok(cmd) = self.script_engine.evaluate(&script, &ctx) { + if let Ok(cmds) = self.script_engine.evaluate(&script, &ctx) { if let Some(step) = self .project_state .project .pattern_at_mut(bank, pattern) .step_mut(step_idx) { - step.command = Some(cmd); + step.command = if cmds.is_empty() { + None + } else { + Some(cmds.join("\n")) + }; } } } @@ -429,6 +437,7 @@ impl App { pub fn save(&mut self, path: PathBuf) { self.save_editor_to_step(); + self.project_state.project.sample_paths = self.audio.config.sample_paths.clone(); match model::save(&self.project_state.project, &path) { Ok(()) => { self.ui.set_status(format!("Saved: {}", path.display())); @@ -459,22 +468,60 @@ impl App { pub fn copy_step(&mut self) { let (bank, pattern) = self.current_bank_pattern(); - let script = pattern_editor::get_step_script( - &self.project_state.project, - bank, - pattern, - self.editor_ctx.step, - ); + let step = self.editor_ctx.step; + let script = + pattern_editor::get_step_script(&self.project_state.project, bank, pattern, step); if let Some(script) = script { if let Some(clip) = &mut self.clipboard { if clip.set_text(&script).is_ok() { + self.editor_ctx.copied_step = Some(crate::state::CopiedStep { + bank, + pattern, + step, + }); self.ui.set_status("Copied".to_string()); } } } } + pub fn delete_step(&mut self, bank: usize, pattern: usize, step: usize) { + let pat = self.project_state.project.pattern_at_mut(bank, pattern); + for s in &mut pat.steps { + if s.source == Some(step) { + s.source = None; + s.script.clear(); + s.command = None; + } + } + + let change = pattern_editor::set_step_script( + &mut self.project_state.project, + bank, + pattern, + step, + String::new(), + ); + if let Some(s) = self + .project_state + .project + .pattern_at_mut(bank, pattern) + .step_mut(step) + { + s.command = None; + s.source = None; + } + self.project_state.mark_dirty(change.bank, change.pattern); + if self.editor_ctx.bank == bank + && self.editor_ctx.pattern == pattern + && self.editor_ctx.step == step + { + self.load_step_to_editor(); + } + self.ui.flash("Step deleted", 150); + } + pub fn paste_step(&mut self, link: &LinkState) { let text = self .clipboard @@ -496,6 +543,86 @@ impl App { } } + pub fn link_paste_step(&mut self) { + let Some(copied) = self.editor_ctx.copied_step else { + self.ui.set_status("Nothing copied".to_string()); + return; + }; + + let (bank, pattern) = self.current_bank_pattern(); + let step = self.editor_ctx.step; + + if copied.bank != bank || copied.pattern != pattern { + self.ui + .set_status("Can only link within same pattern".to_string()); + return; + } + + if copied.step == step { + self.ui.set_status("Cannot link step to itself".to_string()); + return; + } + + let source_step = self + .project_state + .project + .pattern_at(bank, pattern) + .step(copied.step); + if source_step.map(|s| s.source.is_some()).unwrap_or(false) { + self.ui + .set_status("Cannot link to a linked step".to_string()); + return; + } + + if let Some(s) = self + .project_state + .project + .pattern_at_mut(bank, pattern) + .step_mut(step) + { + s.source = Some(copied.step); + s.script.clear(); + s.command = None; + } + self.project_state.mark_dirty(bank, pattern); + self.load_step_to_editor(); + self.ui + .flash(&format!("Linked to step {:02}", copied.step + 1), 150); + } + + pub fn harden_step(&mut self) { + let (bank, pattern) = self.current_bank_pattern(); + let step = self.editor_ctx.step; + + let resolved_script = self + .project_state + .project + .pattern_at(bank, pattern) + .resolve_script(step) + .map(|s| s.to_string()); + + let Some(script) = resolved_script else { + return; + }; + + if let Some(s) = self + .project_state + .project + .pattern_at_mut(bank, pattern) + .step_mut(step) + { + if s.source.is_none() { + self.ui.set_status("Step is not linked".to_string()); + return; + } + s.source = None; + s.script = script; + } + self.project_state.mark_dirty(bank, pattern); + self.load_step_to_editor(); + self.ui.flash("Step hardened", 150); + } + pub fn open_pattern_modal(&mut self, field: PatternField) { let current = match field { PatternField::Length => self.current_edit_pattern().length.to_string(), @@ -566,10 +693,19 @@ impl App { AppCommand::SaveEditorToStep => self.save_editor_to_step(), AppCommand::CompileCurrentStep => self.compile_current_step(link), AppCommand::CompileAllSteps => self.compile_all_steps(link), + AppCommand::DeleteStep { + bank, + pattern, + step, + } => { + self.delete_step(bank, pattern, step); + } // Clipboard AppCommand::CopyStep => self.copy_step(), AppCommand::PasteStep => self.paste_step(link), + AppCommand::LinkPasteStep => self.link_paste_step(), + AppCommand::HardenStep => self.harden_step(), // Pattern playback AppCommand::QueueSlotChange(change) => { @@ -600,7 +736,18 @@ impl App { message, duration_ms, } => self.ui.flash(&message, duration_ms), - AppCommand::OpenModal(modal) => self.ui.modal = modal, + AppCommand::OpenModal(modal) => { + if matches!(modal, Modal::Editor) { + // If current step is a shallow copy, navigate to source step + let pattern = &self.project_state.project.banks[self.editor_ctx.bank].patterns + [self.editor_ctx.pattern]; + if let Some(source) = pattern.steps[self.editor_ctx.step].source { + self.editor_ctx.step = source; + } + self.load_step_to_editor(); + } + self.ui.modal = modal; + } AppCommand::CloseModal => self.ui.modal = Modal::None, AppCommand::OpenPatternModal(field) => self.open_pattern_modal(field), @@ -629,39 +776,32 @@ impl App { // Patterns view AppCommand::PatternsCursorLeft => { - self.patterns_cursor = (self.patterns_cursor + 15) % 16; + self.patterns_nav.move_left(); } AppCommand::PatternsCursorRight => { - self.patterns_cursor = (self.patterns_cursor + 1) % 16; + self.patterns_nav.move_right(); } AppCommand::PatternsCursorUp => { - self.patterns_cursor = (self.patterns_cursor + 12) % 16; + self.patterns_nav.move_up(); } AppCommand::PatternsCursorDown => { - self.patterns_cursor = (self.patterns_cursor + 4) % 16; + self.patterns_nav.move_down(); + } + AppCommand::PatternsEnter => { + let bank = self.patterns_nav.selected_bank(); + let pattern = self.patterns_nav.selected_pattern(); + self.select_edit_bank(bank); + self.select_edit_pattern(pattern); + self.page.down(); + } + AppCommand::PatternsBack => { + self.page.down(); + } + AppCommand::PatternsTogglePlay => { + let bank = self.patterns_nav.selected_bank(); + let pattern = self.patterns_nav.selected_pattern(); + self.toggle_pattern_playback(bank, pattern, snapshot); } - AppCommand::PatternsEnter => match self.patterns_view_level { - PatternsViewLevel::Banks => { - let bank = self.patterns_cursor; - self.patterns_view_level = PatternsViewLevel::Patterns { bank }; - self.patterns_cursor = 0; - } - PatternsViewLevel::Patterns { bank } => { - let pattern = self.patterns_cursor; - self.select_edit_bank(bank); - self.select_edit_pattern(pattern); - self.patterns_view_level = PatternsViewLevel::Banks; - self.patterns_cursor = 0; - self.page.down(); - } - }, - AppCommand::PatternsBack => match self.patterns_view_level { - PatternsViewLevel::Banks => self.page.down(), - PatternsViewLevel::Patterns { .. } => { - self.patterns_view_level = PatternsViewLevel::Banks; - self.patterns_cursor = 0; - } - }, } } @@ -699,6 +839,7 @@ impl App { .map(|s| StepSnapshot { active: s.active, script: s.script.clone(), + source: s.source, }) .collect(), }; diff --git a/seq/src/commands.rs b/seq/src/commands.rs index c4e9c03..132603c 100644 --- a/seq/src/commands.rs +++ b/seq/src/commands.rs @@ -40,10 +40,17 @@ pub enum AppCommand { SaveEditorToStep, CompileCurrentStep, CompileAllSteps, + DeleteStep { + bank: usize, + pattern: usize, + step: usize, + }, // Clipboard CopyStep, PasteStep, + LinkPasteStep, + HardenStep, // Pattern playback QueueSlotChange(SlotChange), @@ -95,4 +102,5 @@ pub enum AppCommand { PatternsCursorDown, PatternsEnter, PatternsBack, + PatternsTogglePlay, } diff --git a/seq/src/engine/sequencer.rs b/seq/src/engine/sequencer.rs index ca2d6ae..af39637 100644 --- a/seq/src/engine/sequencer.rs +++ b/seq/src/engine/sequencer.rs @@ -1,4 +1,5 @@ use crossbeam_channel::{bounded, Receiver, Sender, TrySendError}; +use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::thread::{self, JoinHandle}; @@ -57,6 +58,7 @@ pub struct PatternSnapshot { pub struct StepSnapshot { pub active: bool, pub script: String, + pub source: Option, } #[derive(Clone, Copy, Default)] @@ -229,6 +231,51 @@ impl PatternCache { } } +impl PatternSnapshot { + fn resolve_source(&self, index: usize) -> usize { + let mut current = index; + for _ in 0..self.steps.len() { + if let Some(step) = self.steps.get(current) { + if let Some(source) = step.source { + current = source; + } else { + return current; + } + } else { + return index; + } + } + index + } + + fn resolve_script(&self, index: usize) -> Option<&str> { + let source_idx = self.resolve_source(index); + self.steps.get(source_idx).map(|s| s.script.as_str()) + } +} + +type StepKey = (usize, usize, usize); + +struct RunsCounter { + counts: HashMap, +} + +impl RunsCounter { + fn new() -> Self { + Self { + counts: HashMap::new(), + } + } + + fn get_and_increment(&mut self, bank: usize, pattern: usize, step: usize) -> usize { + let key = (bank, pattern, step); + let count = self.counts.entry(key).or_insert(0); + let current = *count; + *count += 1; + current + } +} + fn sequencer_loop( cmd_rx: Receiver, audio_tx: Sender, @@ -244,6 +291,7 @@ fn sequencer_loop( let script_engine = ScriptEngine::new(variables, rng); let mut audio_state = AudioState::new(); let mut pattern_cache = PatternCache::new(); + let mut runs_counter = RunsCounter::new(); loop { while let Ok(cmd) = cmd_rx.try_recv() { @@ -332,7 +380,15 @@ fn sequencer_loop( slot_steps[slot_idx].store(step_idx, Ordering::Relaxed); if let Some(step) = pattern.steps.get(step_idx) { - if step.active && !step.script.trim().is_empty() { + let resolved_script = pattern.resolve_script(step_idx); + let has_script = resolved_script + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + + if step.active && has_script { + let source_idx = pattern.resolve_source(step_idx); + let runs = + runs_counter.get_and_increment(slot.bank, slot.pattern, source_idx); let ctx = StepContext { step: step_idx, beat, @@ -341,15 +397,20 @@ fn sequencer_loop( tempo, phase: beat % quantum, slot: slot_idx, + runs, }; - if let Ok(cmd) = script_engine.evaluate(&step.script, &ctx) { - match audio_tx.try_send(AudioCommand::Evaluate(cmd)) { - Ok(()) => { - event_count.fetch_add(1, Ordering::Relaxed); - } - Err(TrySendError::Full(_)) => {} - Err(TrySendError::Disconnected(_)) => { - return; + if let Some(script) = resolved_script { + if let Ok(cmds) = script_engine.evaluate(script, &ctx) { + for cmd in cmds { + match audio_tx.try_send(AudioCommand::Evaluate(cmd)) { + Ok(()) => { + event_count.fetch_add(1, Ordering::Relaxed); + } + Err(TrySendError::Full(_)) => {} + Err(TrySendError::Disconnected(_)) => { + return; + } + } } } } diff --git a/seq/src/input.rs b/seq/src/input.rs index 0f38fee..9b7749b 100644 --- a/seq/src/input.rs +++ b/seq/src/input.rs @@ -9,7 +9,7 @@ use crate::commands::AppCommand; use crate::engine::{AudioCommand, LinkState, SequencerSnapshot}; use crate::model::PatternSpeed; use crate::page::Page; -use crate::state::{AudioFocus, Focus, Modal, PatternField, PatternsViewLevel}; +use crate::state::{AudioFocus, Modal, PatternField}; pub enum InputResult { Continue, @@ -31,6 +31,11 @@ impl<'a> InputContext<'a> { } pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult { + if ctx.app.ui.show_title { + ctx.app.ui.show_title = false; + return InputResult::Continue; + } + ctx.dispatch(AppCommand::ClearStatus); if matches!(ctx.app.ui.modal, Modal::None) { @@ -59,6 +64,49 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } _ => {} }, + Modal::ConfirmDeleteStep { + bank, + pattern, + step, + selected: _, + } => { + let (bank, pattern, step) = (*bank, *pattern, *step); + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + ctx.dispatch(AppCommand::DeleteStep { + bank, + pattern, + step, + }); + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Left | KeyCode::Right => { + if let Modal::ConfirmDeleteStep { selected, .. } = &mut ctx.app.ui.modal { + *selected = !*selected; + } + } + KeyCode::Enter => { + let do_delete = + if let Modal::ConfirmDeleteStep { selected, .. } = &ctx.app.ui.modal { + *selected + } else { + false + }; + if do_delete { + ctx.dispatch(AppCommand::DeleteStep { + bank, + pattern, + step, + }); + } + ctx.dispatch(AppCommand::CloseModal); + } + _ => {} + } + } Modal::SaveAs(path) => match key.code { KeyCode::Enter => { let save_path = PathBuf::from(path.as_str()); @@ -77,6 +125,7 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { let load_path = PathBuf::from(path.as_str()); ctx.dispatch(AppCommand::CloseModal); ctx.dispatch(AppCommand::Load(load_path)); + load_project_samples(ctx); } KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), KeyCode::Backspace => { @@ -204,6 +253,23 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { KeyCode::Char(c) => path.push(c), _ => {} }, + Modal::Editor => { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Esc => { + ctx.dispatch(AppCommand::SaveEditorToStep); + ctx.dispatch(AppCommand::CompileCurrentStep); + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Char('e') if ctrl => { + ctx.dispatch(AppCommand::SaveEditorToStep); + ctx.dispatch(AppCommand::CompileCurrentStep); + } + _ => { + ctx.app.editor_ctx.text.input(Event::Key(key)); + } + } + } Modal::None => unreachable!(), } InputResult::Continue @@ -243,64 +309,73 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputResult { - match ctx.app.editor_ctx.focus { - Focus::Sequencer => match key.code { - KeyCode::Char('q') => { - ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { - selected: false, - })); - } - KeyCode::Char(' ') => { - ctx.dispatch(AppCommand::TogglePlaying); - ctx.playing - .store(ctx.app.playback.playing, Ordering::Relaxed); - } - KeyCode::Tab => ctx.dispatch(AppCommand::ToggleFocus), - KeyCode::Left => ctx.dispatch(AppCommand::PrevStep), - KeyCode::Right => ctx.dispatch(AppCommand::NextStep), - KeyCode::Up => ctx.dispatch(AppCommand::StepUp), - KeyCode::Down => ctx.dispatch(AppCommand::StepDown), - KeyCode::Enter => ctx.dispatch(AppCommand::ToggleStep), - KeyCode::Char('s') => { - let default = ctx - .app - .project_state - .file_path - .as_ref() - .map(|p| p.display().to_string()) - .unwrap_or_else(|| "project.buboseq".to_string()); - ctx.dispatch(AppCommand::OpenModal(Modal::SaveAs(default))); - } - KeyCode::Char('l') => { - ctx.dispatch(AppCommand::OpenModal(Modal::LoadFrom(String::new()))); - } - KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp), - KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown), - KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease), - KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease), - KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease), - KeyCode::Char(']') => ctx.dispatch(AppCommand::SpeedIncrease), - KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)), - KeyCode::Char('S') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Speed)), - KeyCode::Char('c') if ctrl => ctx.dispatch(AppCommand::CopyStep), - KeyCode::Char('v') if ctrl => ctx.dispatch(AppCommand::PasteStep), - _ => {} - }, - Focus::Editor => match key.code { - KeyCode::Tab | KeyCode::Esc => ctx.dispatch(AppCommand::ToggleFocus), - KeyCode::Char('e') if ctrl => { - ctx.dispatch(AppCommand::SaveEditorToStep); - ctx.dispatch(AppCommand::CompileCurrentStep); - } - _ => { - ctx.app.editor_ctx.text.input(Event::Key(key)); - } - }, + match key.code { + KeyCode::Char('q') => { + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { + selected: false, + })); + } + KeyCode::Char(' ') => { + ctx.dispatch(AppCommand::TogglePlaying); + ctx.playing + .store(ctx.app.playback.playing, Ordering::Relaxed); + } + KeyCode::Left => ctx.dispatch(AppCommand::PrevStep), + KeyCode::Right => ctx.dispatch(AppCommand::NextStep), + KeyCode::Up => ctx.dispatch(AppCommand::StepUp), + KeyCode::Down => ctx.dispatch(AppCommand::StepDown), + KeyCode::Enter => ctx.dispatch(AppCommand::OpenModal(Modal::Editor)), + KeyCode::Char('t') => ctx.dispatch(AppCommand::ToggleStep), + KeyCode::Char('s') => { + ctx.dispatch(AppCommand::OpenModal(Modal::SaveAs(String::new()))); + } + KeyCode::Char('c') if ctrl => ctx.dispatch(AppCommand::CopyStep), + KeyCode::Char('v') if ctrl => ctx.dispatch(AppCommand::PasteStep), + KeyCode::Char('b') if ctrl => ctx.dispatch(AppCommand::LinkPasteStep), + KeyCode::Char('h') if ctrl => ctx.dispatch(AppCommand::HardenStep), + KeyCode::Char('l') => { + let default_dir = ctx + .app + .project_state + .file_path + .as_ref() + .and_then(|p| p.parent()) + .map(|p| { + let mut s = p.display().to_string(); + if !s.ends_with('/') { + s.push('/'); + } + s + }) + .unwrap_or_default(); + ctx.dispatch(AppCommand::OpenModal(Modal::LoadFrom(default_dir))); + } + KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp), + KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown), + KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease), + KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease), + KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease), + KeyCode::Char(']') => ctx.dispatch(AppCommand::SpeedIncrease), + KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)), + KeyCode::Char('S') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Speed)), + KeyCode::Delete | KeyCode::Backspace => { + let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); + let step = ctx.app.editor_ctx.step; + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmDeleteStep { + bank, + pattern, + step, + selected: false, + })); + } + _ => {} } InputResult::Continue } fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { + use crate::state::PatternsColumn; + match key.code { KeyCode::Left => ctx.dispatch(AppCommand::PatternsCursorLeft), KeyCode::Right => ctx.dispatch(AppCommand::PatternsCursorRight), @@ -308,42 +383,39 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown), KeyCode::Esc | KeyCode::Backspace => ctx.dispatch(AppCommand::PatternsBack), KeyCode::Enter => ctx.dispatch(AppCommand::PatternsEnter), - KeyCode::Char(' ') => { - if let PatternsViewLevel::Patterns { bank } = ctx.app.patterns_view_level { - let pattern = ctx.app.patterns_cursor; - ctx.dispatch(AppCommand::TogglePatternPlayback { bank, pattern }); - } - } + KeyCode::Char(' ') => ctx.dispatch(AppCommand::PatternsTogglePlay), KeyCode::Char('q') => { ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { selected: false, })); } - KeyCode::Char('r') => match ctx.app.patterns_view_level { - PatternsViewLevel::Banks => { - let bank = ctx.app.patterns_cursor; - let current_name = ctx.app.project_state.project.banks[bank] - .name - .clone() - .unwrap_or_default(); - ctx.dispatch(AppCommand::OpenModal(Modal::RenameBank { - bank, - name: current_name, - })); + KeyCode::Char('r') => { + let bank = ctx.app.patterns_nav.bank_cursor; + match ctx.app.patterns_nav.column { + PatternsColumn::Banks => { + let current_name = ctx.app.project_state.project.banks[bank] + .name + .clone() + .unwrap_or_default(); + ctx.dispatch(AppCommand::OpenModal(Modal::RenameBank { + bank, + name: current_name, + })); + } + PatternsColumn::Patterns => { + let pattern = ctx.app.patterns_nav.pattern_cursor; + let current_name = ctx.app.project_state.project.banks[bank].patterns[pattern] + .name + .clone() + .unwrap_or_default(); + ctx.dispatch(AppCommand::OpenModal(Modal::RenamePattern { + bank, + pattern, + name: current_name, + })); + } } - PatternsViewLevel::Patterns { bank } => { - let pattern = ctx.app.patterns_cursor; - let current_name = ctx.app.project_state.project.banks[bank].patterns[pattern] - .name - .clone() - .unwrap_or_default(); - ctx.dispatch(AppCommand::OpenModal(Modal::RenamePattern { - bank, - pattern, - name: current_name, - })); - } - }, + } _ => {} } InputResult::Continue @@ -421,3 +493,29 @@ fn handle_doc_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } InputResult::Continue } + +fn load_project_samples(ctx: &mut InputContext) { + let paths = ctx.app.project_state.project.sample_paths.clone(); + if paths.is_empty() { + return; + } + + let mut total_count = 0; + for path in &paths { + if path.is_dir() { + let index = doux::loader::scan_samples_dir(path); + let count = index.len(); + total_count += count; + let _ = ctx.audio_tx.send(AudioCommand::LoadSamples(index)); + } + } + + ctx.app.audio.config.sample_paths = paths; + ctx.app.audio.config.sample_count = total_count; + + if total_count > 0 { + ctx.dispatch(AppCommand::SetStatus(format!( + "Loaded {total_count} samples from project" + ))); + } +} diff --git a/seq/src/model/file.rs b/seq/src/model/file.rs index 7ce74e6..fad5bc5 100644 --- a/seq/src/model/file.rs +++ b/seq/src/model/file.rs @@ -1,6 +1,6 @@ use std::fs; use std::io; -use std::path::Path; +use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -12,6 +12,8 @@ const VERSION: u8 = 1; struct ProjectFile { version: u8, banks: Vec, + #[serde(default)] + sample_paths: Vec, } impl From<&Project> for ProjectFile { @@ -19,13 +21,17 @@ impl From<&Project> for ProjectFile { Self { version: VERSION, banks: project.banks.clone(), + sample_paths: project.sample_paths.clone(), } } } impl From for Project { fn from(file: ProjectFile) -> Self { - Self { banks: file.banks } + Self { + banks: file.banks, + sample_paths: file.sample_paths, + } } } diff --git a/seq/src/model/forth.rs b/seq/src/model/forth.rs new file mode 100644 index 0000000..89026e2 --- /dev/null +++ b/seq/src/model/forth.rs @@ -0,0 +1,788 @@ +use rand::rngs::StdRng; +use rand::{Rng as RngTrait, SeedableRng}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +pub struct StepContext { + pub step: usize, + pub beat: f64, + pub bank: usize, + pub pattern: usize, + pub tempo: f64, + pub phase: f64, + pub slot: usize, + pub runs: usize, +} + +pub type Variables = Arc>>; +pub type Rng = Arc>; + +#[derive(Clone, Debug)] +pub(crate) enum Value { + Int(i64), + Float(f64), + Str(String), + Cmd(Vec<(String, String)>), + Param(String, String), + Marker, +} + +impl Value { + fn as_float(&self) -> Result { + match self { + Value::Float(f) => Ok(*f), + Value::Int(i) => Ok(*i as f64), + _ => Err("expected number".into()), + } + } + + fn as_int(&self) -> Result { + match self { + Value::Int(i) => Ok(*i), + Value::Float(f) => Ok(*f as i64), + _ => Err("expected number".into()), + } + } + + fn as_str(&self) -> Result<&str, String> { + match self { + Value::Str(s) => Ok(s), + _ => Err("expected string".into()), + } + } + + fn is_truthy(&self) -> bool { + match self { + 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, + } + } + + fn is_marker(&self) -> bool { + 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(), + } + } +} + +#[derive(Clone, Debug)] +enum Op { + PushInt(i64), + PushFloat(f64), + PushStr(String), + Dup, + Drop, + Swap, + Over, + Rot, + Nip, + Tuck, + Add, + Sub, + Mul, + Div, + Mod, + Neg, + Abs, + Min, + Max, + Eq, + Ne, + Lt, + Gt, + Le, + Ge, + And, + Or, + Not, + BranchIfZero(usize), + Branch(usize), + NewCmd, + SetParam(String), + Emit, + Get, + Set, + GetContext(String), + Rand, + Rrand, + Seed, + Cycle, + Choose, + Chance, + Maybe, + Wait, + ListStart, + ListEnd, +} + +#[derive(Clone, Debug)] +enum Token { + Int(i64), + Float(f64), + Str(String), + Word(String), +} + +fn tokenize(input: &str) -> Vec { + let mut tokens = Vec::new(); + let mut chars = input.chars().peekable(); + + while let Some(&c) = chars.peek() { + if c.is_whitespace() { + chars.next(); + continue; + } + + if c == '"' { + chars.next(); + let mut s = String::new(); + while let Some(&ch) = chars.peek() { + if ch == '"' { + chars.next(); + break; + } + s.push(ch); + chars.next(); + } + tokens.push(Token::Str(s)); + continue; + } + + if c == '(' { + while let Some(&ch) = chars.peek() { + chars.next(); + if ch == ')' { + break; + } + } + continue; + } + + let mut word = String::new(); + while let Some(&ch) = chars.peek() { + if ch.is_whitespace() { + break; + } + word.push(ch); + chars.next(); + } + + if let Ok(i) = word.parse::() { + tokens.push(Token::Int(i)); + } else if let Ok(f) = word.parse::() { + tokens.push(Token::Float(f)); + } else { + tokens.push(Token::Word(word)); + } + } + + tokens +} + +const PARAMS: &[&str] = &[ + "time", + "repeat", + "dur", + "gate", + "freq", + "detune", + "speed", + "glide", + "pw", + "spread", + "mult", + "warp", + "mirror", + "harmonics", + "timbre", + "morph", + "begin", + "end", + "gain", + "postgain", + "velocity", + "pan", + "attack", + "decay", + "sustain", + "release", + "lpf", + "lpq", + "lpe", + "lpa", + "lpd", + "lps", + "lpr", + "hpf", + "hpq", + "hpe", + "hpa", + "hpd", + "hps", + "hpr", + "bpf", + "bpq", + "bpe", + "bpa", + "bpd", + "bps", + "bpr", + "ftype", + "penv", + "patt", + "pdec", + "psus", + "prel", + "vib", + "vibmod", + "vibshape", + "fm", + "fmh", + "fmshape", + "fme", + "fma", + "fmd", + "fms", + "fmr", + "am", + "amdepth", + "amshape", + "rm", + "rmdepth", + "rmshape", + "phaser", + "phaserdepth", + "phasersweep", + "phasercenter", + "flanger", + "flangerdepth", + "flangerfeedback", + "chorus", + "chorusdepth", + "chorusdelay", + "comb", + "combfreq", + "combfeedback", + "combdamp", + "coarse", + "crush", + "fold", + "wrap", + "distort", + "distortvol", + "delay", + "delaytime", + "delayfeedback", + "delaytype", + "verb", + "verbdecay", + "verbdamp", + "verbpredelay", + "verbdiff", + "voice", + "orbit", + "note", + "size", + "n", + "cut", + "reset", +]; + +fn compile(tokens: &[Token]) -> Result, String> { + let mut ops = Vec::new(); + let mut i = 0; + + while i < tokens.len() { + match &tokens[i] { + Token::Int(n) => ops.push(Op::PushInt(*n)), + Token::Float(f) => ops.push(Op::PushFloat(*f)), + Token::Str(s) => ops.push(Op::PushStr(s.clone())), + Token::Word(w) => { + let word = w.as_str(); + match word { + "dup" => ops.push(Op::Dup), + "drop" => ops.push(Op::Drop), + "swap" => ops.push(Op::Swap), + "over" => ops.push(Op::Over), + "rot" => ops.push(Op::Rot), + "nip" => ops.push(Op::Nip), + "tuck" => ops.push(Op::Tuck), + "+" => ops.push(Op::Add), + "-" => ops.push(Op::Sub), + "*" => ops.push(Op::Mul), + "/" => ops.push(Op::Div), + "mod" => ops.push(Op::Mod), + "neg" => ops.push(Op::Neg), + "abs" => ops.push(Op::Abs), + "min" => ops.push(Op::Min), + "max" => ops.push(Op::Max), + "=" => ops.push(Op::Eq), + "<>" => ops.push(Op::Ne), + "<" => ops.push(Op::Lt), + ">" => ops.push(Op::Gt), + "<=" => ops.push(Op::Le), + ">=" => ops.push(Op::Ge), + "and" => ops.push(Op::And), + "or" => ops.push(Op::Or), + "not" => ops.push(Op::Not), + "sound" | "s" => ops.push(Op::NewCmd), + "emit" => ops.push(Op::Emit), + "get" => ops.push(Op::Get), + "set" => ops.push(Op::Set), + "rand" => ops.push(Op::Rand), + "rrand" => ops.push(Op::Rrand), + "seed" => ops.push(Op::Seed), + "cycle" => ops.push(Op::Cycle), + "choose" => ops.push(Op::Choose), + "chance" => ops.push(Op::Chance), + "?" => ops.push(Op::Maybe), + "always" => { + ops.push(Op::PushFloat(1.0)); + ops.push(Op::Maybe); + } + "never" => { + ops.push(Op::PushFloat(0.0)); + ops.push(Op::Maybe); + } + "often" => { + ops.push(Op::PushFloat(0.75)); + ops.push(Op::Maybe); + } + "sometimes" => { + ops.push(Op::PushFloat(0.5)); + ops.push(Op::Maybe); + } + "rarely" => { + ops.push(Op::PushFloat(0.25)); + ops.push(Op::Maybe); + } + "almostNever" => { + ops.push(Op::PushFloat(0.1)); + ops.push(Op::Maybe); + } + "almostAlways" => { + ops.push(Op::PushFloat(0.9)); + ops.push(Op::Maybe); + } + "wait" => ops.push(Op::Wait), + "[" => ops.push(Op::ListStart), + "]" => ops.push(Op::ListEnd), + "step" => ops.push(Op::GetContext("step".into())), + "beat" => ops.push(Op::GetContext("beat".into())), + "bank" => ops.push(Op::GetContext("bank".into())), + "pattern" => ops.push(Op::GetContext("pattern".into())), + "tempo" => ops.push(Op::GetContext("tempo".into())), + "phase" => ops.push(Op::GetContext("phase".into())), + "slot" => ops.push(Op::GetContext("slot".into())), + "runs" => ops.push(Op::GetContext("runs".into())), + "if" => { + let (then_ops, else_ops, consumed) = compile_if(&tokens[i + 1..])?; + i += consumed; + if else_ops.is_empty() { + ops.push(Op::BranchIfZero(then_ops.len())); + ops.extend(then_ops); + } else { + ops.push(Op::BranchIfZero(then_ops.len() + 1)); + ops.extend(then_ops); + ops.push(Op::Branch(else_ops.len())); + ops.extend(else_ops); + } + } + _ => { + if PARAMS.contains(&word) { + ops.push(Op::SetParam(word.into())); + } else { + return Err(format!("unknown word: {word}")); + } + } + } + } + } + i += 1; + } + + Ok(ops) +} + +fn compile_if(tokens: &[Token]) -> Result<(Vec, Vec, usize), String> { + let mut depth = 1; + let mut else_pos = None; + let mut then_pos = None; + + for (i, tok) in tokens.iter().enumerate() { + if let Token::Word(w) = tok { + match w.as_str() { + "if" => depth += 1, + "else" if depth == 1 => else_pos = Some(i), + "then" => { + depth -= 1; + if depth == 0 { + then_pos = Some(i); + break; + } + } + _ => {} + } + } + } + + let then_pos = then_pos.ok_or("missing 'then'")?; + + let (then_ops, else_ops) = if let Some(ep) = else_pos { + let then_ops = compile(&tokens[..ep])?; + let else_ops = compile(&tokens[ep + 1..then_pos])?; + (then_ops, else_ops) + } else { + let then_ops = compile(&tokens[..then_pos])?; + (then_ops, Vec::new()) + }; + + Ok((then_ops, else_ops, then_pos + 1)) +} + +pub struct Forth { + vars: Variables, + rng: Rng, +} + +impl Forth { + pub fn new(vars: Variables, rng: Rng) -> Self { + Self { vars, rng } + } + + pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result, String> { + if script.trim().is_empty() { + return Err("empty script".into()); + } + + let tokens = tokenize(script); + let ops = compile(&tokens)?; + self.execute(&ops, ctx) + } + + fn execute(&self, ops: &[Op], ctx: &StepContext) -> Result, String> { + let mut stack: Vec = Vec::new(); + let mut outputs: Vec = Vec::new(); + let mut time_offset: f64 = 0.0; + let mut pc = 0; + + while pc < ops.len() { + match &ops[pc] { + Op::PushInt(n) => stack.push(Value::Int(*n)), + Op::PushFloat(f) => stack.push(Value::Float(*f)), + Op::PushStr(s) => stack.push(Value::Str(s.clone())), + + Op::Dup => { + let v = stack.last().ok_or("stack underflow")?.clone(); + stack.push(v); + } + Op::Drop => { + stack.pop().ok_or("stack underflow")?; + } + Op::Swap => { + let len = stack.len(); + if len < 2 { + return Err("stack underflow".into()); + } + stack.swap(len - 1, len - 2); + } + Op::Over => { + let len = stack.len(); + if len < 2 { + return Err("stack underflow".into()); + } + let v = stack[len - 2].clone(); + stack.push(v); + } + Op::Rot => { + let len = stack.len(); + if len < 3 { + return Err("stack underflow".into()); + } + let v = stack.remove(len - 3); + stack.push(v); + } + Op::Nip => { + let len = stack.len(); + if len < 2 { + return Err("stack underflow".into()); + } + stack.remove(len - 2); + } + Op::Tuck => { + let len = stack.len(); + if len < 2 { + return Err("stack underflow".into()); + } + let v = stack[len - 1].clone(); + stack.insert(len - 2, v); + } + + Op::Add => binary_op(&mut stack, |a, b| a + b)?, + Op::Sub => binary_op(&mut stack, |a, b| a - b)?, + Op::Mul => binary_op(&mut stack, |a, b| a * b)?, + Op::Div => binary_op(&mut stack, |a, b| a / b)?, + Op::Mod => { + let b = stack.pop().ok_or("stack underflow")?.as_int()?; + let a = stack.pop().ok_or("stack underflow")?.as_int()?; + stack.push(Value::Int(a % b)); + } + Op::Neg => { + let v = stack.pop().ok_or("stack underflow")?; + match v { + Value::Int(i) => stack.push(Value::Int(-i)), + Value::Float(f) => stack.push(Value::Float(-f)), + _ => return Err("expected number".into()), + } + } + Op::Abs => { + let v = stack.pop().ok_or("stack underflow")?; + match v { + Value::Int(i) => stack.push(Value::Int(i.abs())), + Value::Float(f) => stack.push(Value::Float(f.abs())), + _ => return Err("expected number".into()), + } + } + Op::Min => binary_op(&mut stack, |a, b| a.min(b))?, + Op::Max => binary_op(&mut stack, |a, b| a.max(b))?, + + Op::Eq => cmp_op(&mut stack, |a, b| (a - b).abs() < f64::EPSILON)?, + Op::Ne => cmp_op(&mut stack, |a, b| (a - b).abs() >= f64::EPSILON)?, + Op::Lt => cmp_op(&mut stack, |a, b| a < b)?, + Op::Gt => cmp_op(&mut stack, |a, b| a > b)?, + Op::Le => cmp_op(&mut stack, |a, b| a <= b)?, + Op::Ge => cmp_op(&mut stack, |a, b| a >= b)?, + + Op::And => { + let b = stack.pop().ok_or("stack underflow")?.is_truthy(); + let a = stack.pop().ok_or("stack underflow")?.is_truthy(); + stack.push(Value::Int(if a && b { 1 } else { 0 })); + } + Op::Or => { + let b = stack.pop().ok_or("stack underflow")?.is_truthy(); + let a = stack.pop().ok_or("stack underflow")?.is_truthy(); + stack.push(Value::Int(if a || b { 1 } else { 0 })); + } + Op::Not => { + let v = stack.pop().ok_or("stack underflow")?.is_truthy(); + stack.push(Value::Int(if v { 0 } else { 1 })); + } + + Op::BranchIfZero(offset) => { + let v = stack.pop().ok_or("stack underflow")?; + if !v.is_truthy() { + pc += offset; + } + } + Op::Branch(offset) => { + pc += offset; + } + + Op::NewCmd => { + let name = stack.pop().ok_or("stack underflow")?; + let name = name.as_str()?; + stack.push(Value::Cmd(vec![("sound".into(), name.into())])); + } + Op::SetParam(param) => { + let val = stack.pop().ok_or("stack underflow")?; + stack.push(Value::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)); + } + } 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())); + } + outputs.push(format_cmd(&pairs)); + } else { + return Err("expected command".into()); + } + } + + Op::Get => { + let name = stack.pop().ok_or("stack underflow")?; + let name = name.as_str()?; + let vars = self.vars.lock().unwrap(); + let val = vars.get(name).cloned().unwrap_or(Value::Int(0)); + stack.push(val); + } + Op::Set => { + let name = stack.pop().ok_or("stack underflow")?; + let name = name.as_str()?.to_string(); + let val = stack.pop().ok_or("stack underflow")?; + self.vars.lock().unwrap().insert(name, val); + } + + Op::GetContext(name) => { + let val = match name.as_str() { + "step" => Value::Int(ctx.step as i64), + "beat" => Value::Float(ctx.beat), + "bank" => Value::Int(ctx.bank as i64), + "pattern" => Value::Int(ctx.pattern as i64), + "tempo" => Value::Float(ctx.tempo), + "phase" => Value::Float(ctx.phase), + "slot" => Value::Int(ctx.slot as i64), + "runs" => Value::Int(ctx.runs as i64), + _ => Value::Int(0), + }; + stack.push(val); + } + + Op::Rand => { + let max = stack.pop().ok_or("stack underflow")?.as_float()?; + let min = stack.pop().ok_or("stack underflow")?.as_float()?; + let val = self.rng.lock().unwrap().gen_range(min..max); + stack.push(Value::Float(val)); + } + Op::Rrand => { + let max = stack.pop().ok_or("stack underflow")?.as_int()?; + let min = stack.pop().ok_or("stack underflow")?.as_int()?; + let val = self.rng.lock().unwrap().gen_range(min..=max); + stack.push(Value::Int(val)); + } + Op::Seed => { + let s = stack.pop().ok_or("stack underflow")?.as_int()?; + *self.rng.lock().unwrap() = StdRng::seed_from_u64(s as u64); + } + + Op::Cycle => { + let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; + if count == 0 { + return Err("cycle count must be > 0".into()); + } + if stack.len() < count { + return Err("stack underflow".into()); + } + let start = stack.len() - count; + let values: Vec = stack.drain(start..).collect(); + let idx = ctx.runs % count; + stack.push(values[idx].clone()); + } + + Op::Choose => { + let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; + if count == 0 { + return Err("choose count must be > 0".into()); + } + if stack.len() < count { + return Err("stack underflow".into()); + } + let start = stack.len() - count; + let values: Vec = stack.drain(start..).collect(); + let idx = self.rng.lock().unwrap().gen_range(0..count); + stack.push(values[idx].clone()); + } + + Op::Chance => { + let prob = stack.pop().ok_or("stack underflow")?.as_float()?; + let val: f64 = self.rng.lock().unwrap().gen(); + stack.push(Value::Int(if val < prob { 1 } else { 0 })); + } + + 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()); + } + let val: f64 = self.rng.lock().unwrap().gen(); + if val < prob { + stack.push(param); + } + } + + Op::Wait => { + let duration = stack.pop().ok_or("stack underflow")?.as_float()?; + time_offset += duration; + } + + Op::ListStart => { + stack.push(Value::Marker); + } + + Op::ListEnd => { + let mut count = 0; + let mut values = Vec::new(); + while let Some(v) = stack.pop() { + if v.is_marker() { + break; + } + values.push(v); + count += 1; + } + values.reverse(); + for v in values { + stack.push(v); + } + stack.push(Value::Int(count)); + } + } + pc += 1; + } + + if outputs.is_empty() { + if let Some(Value::Cmd(pairs)) = stack.pop() { + outputs.push(format_cmd(&pairs)); + } + } + + Ok(outputs) + } +} + +fn binary_op(stack: &mut Vec, f: F) -> Result<(), String> +where + F: Fn(f64, f64) -> f64, +{ + let b = stack.pop().ok_or("stack underflow")?.as_float()?; + let a = stack.pop().ok_or("stack underflow")?.as_float()?; + let result = f(a, b); + if result.fract() == 0.0 && result.abs() < i64::MAX as f64 { + stack.push(Value::Int(result as i64)); + } else { + stack.push(Value::Float(result)); + } + Ok(()) +} + +fn cmp_op(stack: &mut Vec, f: F) -> Result<(), String> +where + F: Fn(f64, f64) -> bool, +{ + let b = stack.pop().ok_or("stack underflow")?.as_float()?; + let a = stack.pop().ok_or("stack underflow")?.as_float()?; + stack.push(Value::Int(if f(a, b) { 1 } else { 0 })); + Ok(()) +} + +fn format_cmd(pairs: &[(String, String)]) -> String { + let parts: Vec = pairs.iter().map(|(k, v)| format!("{k}/{v}")).collect(); + format!("/{}", parts.join("/")) +} diff --git a/seq/src/model/mod.rs b/seq/src/model/mod.rs index de607ee..a7c6643 100644 --- a/seq/src/model/mod.rs +++ b/seq/src/model/mod.rs @@ -1,4 +1,5 @@ mod file; +mod forth; mod project; mod script; diff --git a/seq/src/model/project.rs b/seq/src/model/project.rs index 6d43351..92493cf 100644 --- a/seq/src/model/project.rs +++ b/seq/src/model/project.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use serde::{Deserialize, Serialize}; use crate::config::{DEFAULT_LENGTH, MAX_BANKS, MAX_PATTERNS, MAX_STEPS}; @@ -83,6 +85,8 @@ pub struct Step { pub script: String, #[serde(skip)] pub command: Option, + #[serde(default)] + pub source: Option, } impl Default for Step { @@ -91,6 +95,7 @@ impl Default for Step { active: true, script: String::new(), command: None, + source: None, } } } @@ -132,6 +137,27 @@ impl Pattern { } self.length = length; } + + pub fn resolve_source(&self, index: usize) -> usize { + let mut current = index; + for _ in 0..self.steps.len() { + if let Some(step) = self.steps.get(current) { + if let Some(source) = step.source { + current = source; + } else { + return current; + } + } else { + return index; + } + } + index + } + + pub fn resolve_script(&self, index: usize) -> Option<&str> { + let source_idx = self.resolve_source(index); + self.steps.get(source_idx).map(|s| s.script.as_str()) + } } #[derive(Clone, Serialize, Deserialize)] @@ -153,12 +179,15 @@ impl Default for Bank { #[derive(Clone, Serialize, Deserialize)] pub struct Project { pub banks: Vec, + #[serde(default)] + pub sample_paths: Vec, } impl Default for Project { fn default() -> Self { Self { banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(), + sample_paths: Vec::new(), } } } diff --git a/seq/src/model/script.rs b/seq/src/model/script.rs index b4563e4..e8079e2 100644 --- a/seq/src/model/script.rs +++ b/seq/src/model/script.rs @@ -1,274 +1,19 @@ -use rand::rngs::StdRng; -use rand::{Rng as RngTrait, SeedableRng}; -use rhai::{Dynamic, Engine, Scope}; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; +use super::forth::Forth; -pub type Variables = Arc>>; -pub type Rng = Arc>; - -#[derive(Clone, Debug)] -pub struct Cmd { - pairs: Vec<(String, String)>, -} - -impl Cmd { - fn new() -> Self { - Self { pairs: vec![] } - } - - fn with(sound: &str) -> Self { - let mut cmd = Self::new(); - cmd.pairs.push(("sound".into(), sound.into())); - cmd - } - - fn with_dur_f(sound: &str, dur: f64) -> Self { - let mut cmd = Self::with(sound); - cmd.pairs.push(("dur".into(), dur.to_string())); - cmd - } - - fn with_dur_i(sound: &str, dur: i64) -> Self { - let mut cmd = Self::with(sound); - cmd.pairs.push(("dur".into(), dur.to_string())); - cmd - } - - fn set(&mut self, key: &str, val: &str) -> Self { - self.pairs.push((key.into(), val.into())); - self.clone() - } -} - -impl std::fmt::Display for Cmd { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let parts: Vec = self.pairs.iter().map(|(k, v)| format!("{k}/{v}")).collect(); - write!(f, "/{}", parts.join("/")) - } -} - -pub struct StepContext { - pub step: usize, - pub beat: f64, - pub bank: usize, - pub pattern: usize, - pub tempo: f64, - pub phase: f64, - pub slot: usize, -} +pub use super::forth::{Rng, StepContext, Variables}; pub struct ScriptEngine { - engine: Engine, + forth: Forth, } impl ScriptEngine { pub fn new(vars: Variables, rng: Rng) -> Self { - let mut engine = Engine::new(); - engine.set_max_expr_depths(64, 32); - - register_cmd(&mut engine); - - let vars_for_set = Arc::clone(&vars); - let vars_for_get = Arc::clone(&vars); - - engine.register_fn("set", move |name: &str, value: Dynamic| { - vars_for_set.lock().unwrap().insert(name.to_string(), value); - }); - - engine.register_fn("get", move |name: &str| -> Dynamic { - vars_for_get - .lock() - .unwrap() - .get(name) - .cloned() - .unwrap_or(Dynamic::UNIT) - }); - - let rng_rand_ff = Arc::clone(&rng); - let rng_rand_ii = Arc::clone(&rng); - let rng_rrand_ff = Arc::clone(&rng); - let rng_rrand_ii = Arc::clone(&rng); - let rng_seed = Arc::clone(&rng); - - engine.register_fn("rand", move |min: f64, max: f64| -> f64 { - rng_rand_ff.lock().unwrap().gen_range(min..max) - }); - engine.register_fn("rand", move |min: i64, max: i64| -> f64 { - rng_rand_ii - .lock() - .unwrap() - .gen_range(min as f64..max as f64) - }); - - engine.register_fn("rrand", move |min: f64, max: f64| -> i64 { - rng_rrand_ff - .lock() - .unwrap() - .gen_range(min as i64..=max as i64) - }); - engine.register_fn("rrand", move |min: i64, max: i64| -> i64 { - rng_rrand_ii.lock().unwrap().gen_range(min..=max) - }); - - engine.register_fn("seed", move |s: i64| { - *rng_seed.lock().unwrap() = StdRng::seed_from_u64(s as u64); - }); - - Self { engine } + Self { + forth: Forth::new(vars, rng), + } } - pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result { - if script.trim().is_empty() { - return Err("empty script".to_string()); - } - - let mut scope = Scope::new(); - scope.push("step", ctx.step as i64); - scope.push("beat", ctx.beat); - scope.push("bank", ctx.bank as i64); - scope.push("pattern", ctx.pattern as i64); - scope.push("tempo", ctx.tempo); - scope.push("phase", ctx.phase); - scope.push("slot", ctx.slot as i64); - - if let Ok(cmd) = self.engine.eval_with_scope::(&mut scope, script) { - return Ok(cmd.to_string()); - } - - self.engine - .eval_with_scope::(&mut scope, script) - .map_err(|e| e.to_string()) + pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result, String> { + self.forth.evaluate(script, ctx) } } - -fn register_cmd(engine: &mut Engine) { - engine.register_type_with_name::("Cmd"); - engine.register_fn("sound", Cmd::with); - engine.register_fn("sound", Cmd::with_dur_f); - engine.register_fn("sound", Cmd::with_dur_i); - engine.register_fn("s", Cmd::with); - engine.register_fn("s", Cmd::with_dur_f); - engine.register_fn("s", Cmd::with_dur_i); - - macro_rules! reg_both { - ($($name:expr),*) => { - $( - engine.register_fn($name, |c: &mut Cmd, v: f64| c.set($name, &v.to_string())); - engine.register_fn($name, |c: &mut Cmd, v: i64| c.set($name, &v.to_string())); - )* - }; - } - - reg_both!( - "time", - "repeat", - "dur", - "gate", - "freq", - "detune", - "speed", - "glide", - "pw", - "spread", - "mult", - "warp", - "mirror", - "harmonics", - "timbre", - "morph", - "begin", - "end", - "gain", - "postgain", - "velocity", - "pan", - "attack", - "decay", - "sustain", - "release", - "lpf", - "lpq", - "lpe", - "lpa", - "lpd", - "lps", - "lpr", - "hpf", - "hpq", - "hpe", - "hpa", - "hpd", - "hps", - "hpr", - "bpf", - "bpq", - "bpe", - "bpa", - "bpd", - "bps", - "bpr", - "ftype", - "penv", - "patt", - "pdec", - "psus", - "prel", - "vib", - "vibmod", - "vibshape", - "fm", - "fmh", - "fmshape", - "fme", - "fma", - "fmd", - "fms", - "fmr", - "am", - "amdepth", - "amshape", - "rm", - "rmdepth", - "rmshape", - "phaser", - "phaserdepth", - "phasersweep", - "phasercenter", - "flanger", - "flangerdepth", - "flangerfeedback", - "chorus", - "chorusdepth", - "chorusdelay", - "comb", - "combfreq", - "combfeedback", - "combdamp", - "coarse", - "crush", - "fold", - "wrap", - "distort", - "distortvol", - "delay", - "delaytime", - "delayfeedback", - "delaytype", - "verb", - "verbdecay", - "verbdamp", - "verbpredelay", - "verbdiff", - "voice", - "orbit", - "note", - "size", - "n", - "cut" - ); - - engine.register_fn("reset", |c: &mut Cmd, v: bool| { - c.set("reset", if v { "1" } else { "0" }) - }); -} diff --git a/seq/src/state/editor.rs b/seq/src/state/editor.rs index a9b1f9a..4b833b3 100644 --- a/seq/src/state/editor.rs +++ b/seq/src/state/editor.rs @@ -18,6 +18,14 @@ pub struct EditorContext { pub step: usize, pub focus: Focus, pub text: TextArea<'static>, + pub copied_step: Option, +} + +#[derive(Clone, Copy)] +pub struct CopiedStep { + pub bank: usize, + pub pattern: usize, + pub step: usize, } impl Default for EditorContext { @@ -28,6 +36,7 @@ impl Default for EditorContext { step: 0, focus: Focus::Sequencer, text: TextArea::default(), + copied_step: None, } } } diff --git a/seq/src/state/mod.rs b/seq/src/state/mod.rs index b63c0f4..0eb42b2 100644 --- a/seq/src/state/mod.rs +++ b/seq/src/state/mod.rs @@ -7,9 +7,9 @@ pub mod project; pub mod ui; pub use audio::{AudioFocus, AudioSettings, Metrics}; -pub use editor::{EditorContext, Focus, PatternField}; +pub use editor::{CopiedStep, EditorContext, Focus, PatternField}; pub use modal::Modal; -pub use patterns_nav::PatternsViewLevel; +pub use patterns_nav::{PatternsColumn, PatternsNav}; pub use playback::PlaybackState; pub use project::ProjectState; pub use ui::UiState; diff --git a/seq/src/state/modal.rs b/seq/src/state/modal.rs index 5584f70..22c5cf1 100644 --- a/seq/src/state/modal.rs +++ b/seq/src/state/modal.rs @@ -6,6 +6,12 @@ pub enum Modal { ConfirmQuit { selected: bool, }, + ConfirmDeleteStep { + bank: usize, + pattern: usize, + step: usize, + selected: bool, + }, SaveAs(String), LoadFrom(String), RenameBank { @@ -22,4 +28,5 @@ pub enum Modal { input: String, }, AddSamplePath(String), + Editor, } diff --git a/seq/src/state/patterns_nav.rs b/seq/src/state/patterns_nav.rs index 588d643..d2676cb 100644 --- a/seq/src/state/patterns_nav.rs +++ b/seq/src/state/patterns_nav.rs @@ -1,8 +1,53 @@ #[derive(Clone, Copy, PartialEq, Eq, Default)] -pub enum PatternsViewLevel { +pub enum PatternsColumn { #[default] Banks, - Patterns { - bank: usize, - }, + Patterns, +} + +#[derive(Clone, Copy, Default)] +pub struct PatternsNav { + pub column: PatternsColumn, + pub bank_cursor: usize, + pub pattern_cursor: usize, +} + +impl PatternsNav { + pub fn move_left(&mut self) { + self.column = PatternsColumn::Banks; + } + + pub fn move_right(&mut self) { + self.column = PatternsColumn::Patterns; + } + + pub fn move_up(&mut self) { + match self.column { + PatternsColumn::Banks => { + self.bank_cursor = (self.bank_cursor + 15) % 16; + } + PatternsColumn::Patterns => { + self.pattern_cursor = (self.pattern_cursor + 15) % 16; + } + } + } + + pub fn move_down(&mut self) { + match self.column { + PatternsColumn::Banks => { + self.bank_cursor = (self.bank_cursor + 1) % 16; + } + PatternsColumn::Patterns => { + self.pattern_cursor = (self.pattern_cursor + 1) % 16; + } + } + } + + pub fn selected_bank(&self) -> usize { + self.bank_cursor + } + + pub fn selected_pattern(&self) -> usize { + self.pattern_cursor + } } diff --git a/seq/src/state/ui.rs b/seq/src/state/ui.rs index 7c3ab3c..d5daae3 100644 --- a/seq/src/state/ui.rs +++ b/seq/src/state/ui.rs @@ -8,6 +8,7 @@ pub struct UiState { pub modal: Modal, pub doc_topic: usize, pub doc_scroll: usize, + pub show_title: bool, } impl Default for UiState { @@ -18,6 +19,7 @@ impl Default for UiState { modal: Modal::None, doc_topic: 0, doc_scroll: 0, + show_title: true, } } } diff --git a/seq/src/views/highlight.rs b/seq/src/views/highlight.rs new file mode 100644 index 0000000..dae12f4 --- /dev/null +++ b/seq/src/views/highlight.rs @@ -0,0 +1,277 @@ +use ratatui::style::{Color, Style}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum TokenKind { + Number, + String, + Comment, + Keyword, + StackOp, + Operator, + Sound, + Param, + Context, + Default, +} + +impl TokenKind { + pub fn style(self) -> Style { + match self { + TokenKind::Number => Style::default().fg(Color::Rgb(255, 180, 100)), + TokenKind::String => Style::default().fg(Color::Rgb(150, 220, 150)), + TokenKind::Comment => Style::default().fg(Color::Rgb(100, 100, 100)), + TokenKind::Keyword => Style::default().fg(Color::Rgb(220, 120, 220)), + TokenKind::StackOp => Style::default().fg(Color::Rgb(120, 180, 220)), + TokenKind::Operator => Style::default().fg(Color::Rgb(200, 200, 130)), + TokenKind::Sound => Style::default().fg(Color::Rgb(100, 220, 200)), + TokenKind::Param => Style::default().fg(Color::Rgb(180, 150, 220)), + TokenKind::Context => Style::default().fg(Color::Rgb(220, 180, 120)), + TokenKind::Default => Style::default().fg(Color::Rgb(200, 200, 200)), + } + } +} + +pub struct Token { + pub start: usize, + pub end: usize, + pub kind: TokenKind, +} + +const STACK_OPS: &[&str] = &["dup", "drop", "swap", "over", "rot", "nip", "tuck"]; +const OPERATORS: &[&str] = &[ + "+", "-", "*", "/", "mod", "neg", "abs", "min", "max", "=", "<>", "<", ">", "<=", ">=", "and", + "or", "not", +]; +const KEYWORDS: &[&str] = &[ + "if", "else", "then", "emit", "get", "set", "rand", "rrand", "seed", "cycle", "choose", + "chance", "[", "]", +]; +const SOUND: &[&str] = &["sound", "s"]; +const CONTEXT: &[&str] = &[ + "step", "beat", "bank", "pattern", "tempo", "phase", "slot", "runs", +]; +const PARAMS: &[&str] = &[ + "time", + "repeat", + "dur", + "gate", + "freq", + "detune", + "speed", + "glide", + "pw", + "spread", + "mult", + "warp", + "mirror", + "harmonics", + "timbre", + "morph", + "begin", + "end", + "gain", + "postgain", + "velocity", + "pan", + "attack", + "decay", + "sustain", + "release", + "lpf", + "lpq", + "lpe", + "lpa", + "lpd", + "lps", + "lpr", + "hpf", + "hpq", + "hpe", + "hpa", + "hpd", + "hps", + "hpr", + "bpf", + "bpq", + "bpe", + "bpa", + "bpd", + "bps", + "bpr", + "ftype", + "penv", + "patt", + "pdec", + "psus", + "prel", + "vib", + "vibmod", + "vibshape", + "fm", + "fmh", + "fmshape", + "fme", + "fma", + "fmd", + "fms", + "fmr", + "am", + "amdepth", + "amshape", + "rm", + "rmdepth", + "rmshape", + "phaser", + "phaserdepth", + "phasersweep", + "phasercenter", + "flanger", + "flangerdepth", + "flangerfeedback", + "chorus", + "chorusdepth", + "chorusdelay", + "comb", + "combfreq", + "combfeedback", + "combdamp", + "coarse", + "crush", + "fold", + "wrap", + "distort", + "distortvol", + "delay", + "delaytime", + "delayfeedback", + "delaytype", + "verb", + "verbdecay", + "verbdamp", + "verbpredelay", + "verbdiff", + "voice", + "orbit", + "note", + "size", + "n", + "cut", + "reset", +]; + +pub fn tokenize_line(line: &str) -> Vec { + let mut tokens = Vec::new(); + let mut chars = line.char_indices().peekable(); + + while let Some((start, c)) = chars.next() { + if c.is_whitespace() { + continue; + } + + if c == '(' { + let end = line.len(); + let comment_end = line[start..] + .find(')') + .map(|i| start + i + 1) + .unwrap_or(end); + tokens.push(Token { + start, + end: comment_end, + kind: TokenKind::Comment, + }); + while let Some((i, _)) = chars.peek() { + if *i >= comment_end { + break; + } + chars.next(); + } + continue; + } + + if c == '"' { + let mut end = start + 1; + while let Some((i, ch)) = chars.next() { + end = i + ch.len_utf8(); + if ch == '"' { + break; + } + } + tokens.push(Token { + start, + end, + kind: TokenKind::String, + }); + continue; + } + + let mut end = start + c.len_utf8(); + while let Some((i, ch)) = chars.peek() { + if ch.is_whitespace() { + break; + } + end = *i + ch.len_utf8(); + chars.next(); + } + + let word = &line[start..end]; + let kind = classify_word(word); + tokens.push(Token { start, end, kind }); + } + + tokens +} + +fn classify_word(word: &str) -> TokenKind { + if word.parse::().is_ok() || word.parse::().is_ok() { + return TokenKind::Number; + } + + if STACK_OPS.contains(&word) { + return TokenKind::StackOp; + } + + if OPERATORS.contains(&word) { + return TokenKind::Operator; + } + + if KEYWORDS.contains(&word) { + return TokenKind::Keyword; + } + + if SOUND.contains(&word) { + return TokenKind::Sound; + } + + if CONTEXT.contains(&word) { + return TokenKind::Context; + } + + if PARAMS.contains(&word) { + return TokenKind::Param; + } + + TokenKind::Default +} + +pub fn highlight_line(line: &str) -> Vec<(Style, String)> { + let tokens = tokenize_line(line); + let mut result = Vec::new(); + let mut last_end = 0; + + for token in tokens { + if token.start > last_end { + result.push(( + TokenKind::Default.style(), + line[last_end..token.start].to_string(), + )); + } + result.push((token.kind.style(), line[token.start..token.end].to_string())); + last_end = token.end; + } + + if last_end < line.len() { + result.push((TokenKind::Default.style(), line[last_end..].to_string())); + } + + result +} diff --git a/seq/src/views/main_view.rs b/seq/src/views/main_view.rs index a65606a..b19e77e 100644 --- a/seq/src/views/main_view.rs +++ b/seq/src/views/main_view.rs @@ -1,11 +1,12 @@ 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::Frame; use crate::app::App; use crate::engine::SequencerSnapshot; -use crate::state::Focus; +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) { @@ -16,32 +17,22 @@ pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, ar ]) .areas(area); - let [seq_area, editor_area] = - Layout::vertical([Constraint::Fill(3), Constraint::Fill(2)]).areas(main_area); + let [sequencer_area, preview_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]).areas(main_area); - render_sequencer(frame, app, snapshot, seq_area); - render_editor(frame, app, editor_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 focus_indicator = if app.editor_ctx.focus == Focus::Sequencer { - "*" - } else { - " " - }; - - let border_style = if app.editor_ctx.focus == Focus::Sequencer { - Style::new().fg(Color::Rgb(100, 160, 180)) - } else { - Style::new().fg(Color::Rgb(70, 75, 85)) - }; + let border_style = Style::new().fg(Color::Rgb(100, 160, 180)); let block = Block::default() .borders(Borders::ALL) .border_style(border_style) - .title(format!("Sequencer{focus_indicator}")); + .title("Sequencer"); let inner = block.inner(area); frame.render_widget(block, area); @@ -116,6 +107,7 @@ fn render_tile( let pattern = app.current_edit_pattern(); let step = pattern.step(step_idx); let is_active = step.map(|s| s.active).unwrap_or(false); + let is_linked = step.map(|s| s.source.is_some()).unwrap_or(false); let is_selected = step_idx == app.editor_ctx.step; let playing_slot = if app.playback.playing { @@ -132,17 +124,21 @@ fn render_tile( let is_playing = playing_slot.is_some(); - let (bg, fg) = match (is_playing, is_active, is_selected) { - (true, true, _) => (Color::Rgb(195, 85, 65), Color::White), - (true, false, _) => (Color::Rgb(180, 120, 45), Color::Black), - (false, true, true) => (Color::Rgb(0, 220, 180), Color::Black), - (false, true, false) => (Color::Rgb(45, 106, 95), Color::White), - (false, false, true) => (Color::Rgb(80, 180, 255), Color::Black), - (false, false, false) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)), + let (bg, fg) = match (is_playing, is_active, is_selected, is_linked) { + (true, true, _, _) => (Color::Rgb(195, 85, 65), Color::White), + (true, false, _, _) => (Color::Rgb(180, 120, 45), Color::Black), + (false, true, true, true) => (Color::Rgb(180, 140, 220), Color::Black), + (false, true, true, false) => (Color::Rgb(0, 220, 180), Color::Black), + (false, true, false, true) => (Color::Rgb(90, 70, 120), Color::White), + (false, true, false, false) => (Color::Rgb(45, 106, 95), Color::White), + (false, false, true, _) => (Color::Rgb(80, 180, 255), Color::Black), + (false, false, false, _) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)), }; let symbol = if is_playing { "▶".to_string() + } else if let Some(source) = step.and_then(|s| s.source) { + format!("→{:02}", source + 1) } else { format!("{:02}", step_idx + 1) }; @@ -154,40 +150,6 @@ fn render_tile( frame.render_widget(tile, area); } -fn render_editor(frame: &mut Frame, app: &mut App, area: Rect) { - let focus_indicator = if app.editor_ctx.focus == Focus::Editor { - "*" - } else { - " " - }; - - let border_style = if app.ui.is_flashing() { - Style::new().fg(Color::Green) - } else if app.editor_ctx.focus == Focus::Editor { - Style::new().fg(Color::Rgb(100, 160, 180)) - } else { - Style::new().fg(Color::Rgb(70, 75, 85)) - }; - - let step_num = app.editor_ctx.step + 1; - let block = Block::default() - .borders(Borders::ALL) - .border_style(border_style) - .title(format!("Step {step_num:02} Script{focus_indicator}")); - - let inner = block.inner(area); - frame.render_widget(block, area); - - let cursor_style = if app.editor_ctx.focus == Focus::Editor { - Style::new().bg(Color::White).fg(Color::Black) - } else { - Style::default() - }; - app.editor_ctx.text.set_cursor_style(cursor_style); - - frame.render_widget(&app.editor_ctx.text, inner); -} - fn render_scope(frame: &mut Frame, app: &App, area: Rect) { let block = Block::default() .borders(Borders::ALL) @@ -213,3 +175,45 @@ fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) { let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right); frame.render_widget(vu, inner); } + +fn render_step_preview(frame: &mut Frame, app: &App, area: Rect) { + let pattern = app.current_edit_pattern(); + let step_idx = app.editor_ctx.step; + let step = pattern.step(step_idx); + + let [title_area, content_area] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area); + + let is_linked = step.map(|s| s.source.is_some()).unwrap_or(false); + let source_idx = step.and_then(|s| s.source); + + let title = if let Some(src) = source_idx { + format!(" Step {:02} → {:02} ", step_idx + 1, src + 1) + } else { + format!(" Step {:02} ", step_idx + 1) + }; + let title_color = if is_linked { + Color::Rgb(180, 140, 220) + } else { + Color::Rgb(120, 125, 135) + }; + let title_p = Paragraph::new(title).style(Style::new().fg(title_color)); + frame.render_widget(title_p, title_area); + + let script = pattern.resolve_script(step_idx).unwrap_or(""); + if script.is_empty() { + let empty = Paragraph::new(" (empty)").style(Style::new().fg(Color::Rgb(80, 85, 95))); + frame.render_widget(empty, content_area); + return; + } + + let spans: Vec<_> = highlight_line(script) + .into_iter() + .map(|(style, text)| ratatui::text::Span::styled(text, style)) + .collect(); + let mut line_spans = vec![ratatui::text::Span::raw(" ")]; + line_spans.extend(spans); + let line = Line::from(line_spans); + let paragraph = Paragraph::new(line); + frame.render_widget(paragraph, content_area); +} diff --git a/seq/src/views/mod.rs b/seq/src/views/mod.rs index 0ce84b9..702d23a 100644 --- a/seq/src/views/mod.rs +++ b/seq/src/views/mod.rs @@ -1,7 +1,9 @@ pub mod audio_view; pub mod doc_view; +pub mod highlight; pub mod main_view; pub mod patterns_view; mod render; +pub mod title_view; pub use render::render; diff --git a/seq/src/views/patterns_view.rs b/seq/src/views/patterns_view.rs index 319f42d..c10c8f1 100644 --- a/seq/src/views/patterns_view.rs +++ b/seq/src/views/patterns_view.rs @@ -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 = 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> = app - .project_state - .project - .banks + let banks_with_queued: Vec = 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 = (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 = 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> = app.project_state.project.banks[bank] - .patterns + let queued_to_play: Vec = 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 = 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 = (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); } } diff --git a/seq/src/views/render.rs b/seq/src/views/render.rs index 9b404f3..1fb31df 100644 --- a/seq/src/views/render.rs +++ b/seq/src/views/render.rs @@ -1,4 +1,4 @@ -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}; @@ -8,17 +8,25 @@ use crate::app::App; use crate::engine::{LinkState, SequencerSnapshot}; use crate::page::Page; use crate::state::{Modal, PatternField}; -use crate::widgets::{ConfirmModal, TextInputModal}; +use crate::views::highlight; +use crate::widgets::{ConfirmModal, ModalFrame, TextInputModal}; -use super::{audio_view, doc_view, main_view, patterns_view}; +use super::{audio_view, doc_view, main_view, patterns_view, title_view}; pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &SequencerSnapshot) { + let term = frame.area(); + + if app.ui.show_title { + title_view::render(frame, term); + return; + } + let [header_area, body_area, footer_area] = Layout::vertical([ - Constraint::Length(1), + Constraint::Length(2), Constraint::Fill(1), Constraint::Length(3), ]) - .areas(frame.area()); + .areas(term); render_header(frame, app, link, header_area); @@ -30,12 +38,12 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq } render_footer(frame, app, footer_area); - render_modal(frame, app); + render_modal(frame, app, term); } fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { - let [left_area, right_area] = - Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area); + let [top_row, bottom_row] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area); let play_symbol = if app.playback.playing { "▶" } else { "■" }; let play_color = if app.playback.playing { @@ -53,8 +61,30 @@ fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { Color::Green }; - let left_spans = vec![ - Span::styled("EDIT ", Style::new().fg(Color::Cyan)), + 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( format!( "B{:02}:P{:02}", @@ -64,43 +94,18 @@ fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::styled(play_symbol, Style::new().fg(play_color)), - ]; - - frame.render_widget(Paragraph::new(Line::from(left_spans)), left_area); - - let pattern = app - .project_state - .project - .pattern_at(app.editor_ctx.bank, app.editor_ctx.pattern); - let right_spans = vec![ Span::styled( format!("L:{:02}", pattern.length), Style::new().fg(Color::Rgb(180, 140, 90)), ), - Span::raw(" "), + Span::raw(" "), Span::styled( format!("S:{}", pattern.speed.label()), Style::new().fg(Color::Rgb(180, 140, 90)), ), - Span::raw(" "), - Span::styled( - format!("{:.1} BPM", link.tempo()), - Style::new().fg(Color::Magenta), - ), - Span::raw(" "), - Span::styled(format!("CPU:{cpu_pct:.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(right_spans)).alignment(Alignment::Right), - right_area, - ); + frame.render_widget(Paragraph::new(Line::from(bottom_spans)), bottom_row); } fn render_footer(frame: &mut Frame, app: &App, area: Rect) { @@ -128,16 +133,16 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { ), 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("Tab", Style::new().fg(Color::Yellow)), - Span::raw(":focus "), Span::styled("s/l", Style::new().fg(Color::Yellow)), - Span::raw(":save/load "), - Span::styled("C-↑", Style::new().fg(Color::Yellow)), - Span::raw(":patterns"), + Span::raw(":save/load"), ]), Page::Patterns => Line::from(vec![ Span::styled( @@ -190,13 +195,16 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(footer, area); } -fn render_modal(frame: &mut Frame, app: &App) { - let term = frame.area(); +fn render_modal(frame: &mut Frame, app: &App, term: Rect) { match &app.ui.modal { Modal::None => {} Modal::ConfirmQuit { selected } => { ConfirmModal::new("Confirm", "Quit?", *selected).render_centered(frame, term); } + Modal::ConfirmDeleteStep { step, selected, .. } => { + ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected) + .render_centered(frame, term); + } Modal::SaveAs(path) => { TextInputModal::new("Save As (Enter to confirm, Esc to cancel)", path) .width(60) @@ -246,5 +254,79 @@ fn render_modal(frame: &mut Frame, app: &App) { .border_color(Color::Magenta) .render_centered(frame, term); } + Modal::Editor => { + let width = (term.width * 80 / 100).max(40); + let height = (term.height * 60 / 100).max(10); + let step_num = app.editor_ctx.step + 1; + + let border_color = if app.ui.is_flashing() { + Color::Green + } else { + Color::Rgb(100, 160, 180) + }; + + let inner = ModalFrame::new(&format!("Step {step_num:02} Script")) + .width(width) + .height(height) + .border_color(border_color) + .render_centered(frame, term); + + let (cursor_row, cursor_col) = app.editor_ctx.text.cursor(); + + let lines: Vec = app + .editor_ctx + .text + .lines() + .iter() + .enumerate() + .map(|(row, line)| { + let mut spans: Vec = Vec::new(); + let tokens = highlight::highlight_line(line); + + if row == cursor_row { + let mut col = 0; + for (style, text) in tokens { + let text_len = text.chars().count(); + if cursor_col >= col && cursor_col < col + text_len { + let before = + text.chars().take(cursor_col - col).collect::(); + let cursor_char = text.chars().nth(cursor_col - col).unwrap_or(' '); + let after = + text.chars().skip(cursor_col - col + 1).collect::(); + + if !before.is_empty() { + spans.push(Span::styled(before, style)); + } + spans.push(Span::styled( + cursor_char.to_string(), + Style::default().bg(Color::White).fg(Color::Black), + )); + if !after.is_empty() { + spans.push(Span::styled(after, style)); + } + } else { + spans.push(Span::styled(text, style)); + } + col += text_len; + } + if cursor_col >= col { + spans.push(Span::styled( + " ", + Style::default().bg(Color::White).fg(Color::Black), + )); + } + } else { + for (style, text) in tokens { + spans.push(Span::styled(text, style)); + } + } + + Line::from(spans) + }) + .collect(); + + let paragraph = Paragraph::new(lines); + frame.render_widget(paragraph, inner); + } } } diff --git a/seq/src/views/title_view.rs b/seq/src/views/title_view.rs new file mode 100644 index 0000000..843b3c5 --- /dev/null +++ b/seq/src/views/title_view.rs @@ -0,0 +1,51 @@ +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +pub fn render(frame: &mut Frame, area: Rect) { + let title_style = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD); + let subtitle_style = Style::new().fg(Color::White); + let dim_style = Style::new() + .fg(Color::Rgb(120, 125, 135)) + .add_modifier(Modifier::DIM); + let link_style = Style::new().fg(Color::Rgb(100, 160, 180)); + + let lines = vec![ + Line::from(""), + Line::from(""), + Line::from(Span::styled("seq", title_style)), + Line::from(""), + Line::from(Span::styled("A Forth Music Sequencer", subtitle_style)), + Line::from(""), + Line::from(""), + Line::from(Span::styled("by BuboBubo", dim_style)), + Line::from(Span::styled("Raphael Maurice Forment", dim_style)), + Line::from(""), + Line::from(Span::styled("https://raphaelforment.fr", link_style)), + Line::from(""), + Line::from(""), + Line::from(Span::styled("AGPL-3.0", dim_style)), + Line::from(""), + Line::from(""), + Line::from(""), + Line::from(Span::styled( + "Press any key to continue", + Style::new().fg(Color::DarkGray), + )), + ]; + + let text_height = lines.len() as u16; + let vertical_padding = area.height.saturating_sub(text_height) / 2; + + let [_, center_area, _] = Layout::vertical([ + Constraint::Length(vertical_padding), + Constraint::Length(text_height), + Constraint::Fill(1), + ]) + .areas(area); + + let paragraph = Paragraph::new(lines).alignment(Alignment::Center); + frame.render_widget(paragraph, center_area); +} diff --git a/seq/src/widgets/vu_meter.rs b/seq/src/widgets/vu_meter.rs index 4e55d29..c5deb64 100644 --- a/seq/src/widgets/vu_meter.rs +++ b/seq/src/widgets/vu_meter.rs @@ -3,6 +3,10 @@ use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::widgets::Widget; +const DB_MIN: f32 = -48.0; +const DB_MAX: f32 = 3.0; +const DB_RANGE: f32 = DB_MAX - DB_MIN; + pub struct VuMeter { left: f32, right: f32, @@ -13,10 +17,22 @@ impl VuMeter { Self { left, right } } - fn level_to_color(level: f32) -> Color { - if level > 0.9 { + fn amplitude_to_db(amp: f32) -> f32 { + if amp <= 0.0 { + DB_MIN + } else { + (20.0 * amp.log10()).clamp(DB_MIN, DB_MAX) + } + } + + fn db_to_normalized(db: f32) -> f32 { + (db - DB_MIN) / DB_RANGE + } + + fn row_to_color(row_position: f32) -> Color { + if row_position > 0.9 { Color::Red - } else if level > 0.7 { + } else if row_position > 0.75 { Color::Yellow } else { Color::Green @@ -26,40 +42,38 @@ impl VuMeter { impl Widget for VuMeter { fn render(self, area: Rect, buf: &mut Buffer) { - if area.width < 2 || area.height == 0 { + if area.width < 3 || area.height == 0 { return; } let height = area.height as usize; - let left_col = area.x; - let right_col = area.x + area.width - 1; + let half_width = area.width / 2; + let gap = 1u16; - let left_level = (self.left.clamp(0.0, 1.0) * height as f32) as usize; - let right_level = (self.right.clamp(0.0, 1.0) * height as f32) as usize; + let left_db = Self::amplitude_to_db(self.left); + let right_db = Self::amplitude_to_db(self.right); + let left_norm = Self::db_to_normalized(left_db); + let right_norm = Self::db_to_normalized(right_db); + + let left_rows = (left_norm * height as f32).round() as usize; + let right_rows = (right_norm * height as f32).round() as usize; for row in 0..height { let y = area.y + area.height - 1 - row as u16; - let level_at_row = (row as f32 + 0.5) / height as f32; - let color = Self::level_to_color(level_at_row); + let row_position = (row as f32 + 0.5) / height as f32; + let color = Self::row_to_color(row_position); - if row < left_level { - buf[(left_col, y)].set_char('█').set_fg(color); - } else { - buf[(left_col, y)].set_char('░').set_fg(Color::DarkGray); + for col in 0..half_width.saturating_sub(gap) { + let x = area.x + col; + if row < left_rows { + buf[(x, y)].set_char(' ').set_bg(color); + } } - if row < right_level { - buf[(right_col, y)].set_char('█').set_fg(color); - } else { - buf[(right_col, y)].set_char('░').set_fg(Color::DarkGray); - } - } - - if area.width > 2 { - for row in 0..height { - let y = area.y + row as u16; - for x in (area.x + 1)..(area.x + area.width - 1) { - buf[(x, y)].set_char(' '); + for col in 0..half_width.saturating_sub(gap) { + let x = area.x + half_width + gap + col; + if x < area.x + area.width && row < right_rows { + buf[(x, y)].set_char(' ').set_bg(color); } } } diff --git a/src/event.rs b/src/event.rs index 82f9e89..f6a8f9b 100644 --- a/src/event.rs +++ b/src/event.rs @@ -6,6 +6,7 @@ pub struct Event { // Timing pub time: Option, + pub delta: Option, pub repeat: Option, pub duration: Option, pub gate: Option, @@ -172,6 +173,7 @@ impl Event { match key { "doux" | "dirt" => event.cmd = Some(val.to_string()), "time" | "t" => event.time = val.parse().ok(), + "delta" => event.delta = val.parse().ok(), "repeat" | "rep" => event.repeat = val.parse().ok(), "duration" | "dur" | "d" => event.duration = val.parse().ok(), "gate" => event.gate = val.parse().ok(), diff --git a/src/lib.rs b/src/lib.rs index f7440b2..abeb390 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -263,7 +263,11 @@ impl Engine { } } - fn play_event(&mut self, event: Event) -> Option { + fn play_event(&mut self, mut event: Event) -> Option { + if let Some(delta) = event.delta { + event.time = Some(self.time + delta); + event.delta = None; + } if event.time.is_some() { // ALL events with time go to schedule (like dough.c) // This ensures repeat works correctly for time=0 events