diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 2579538..bebabd1 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1,10 +1,10 @@ mod file; mod project; -pub const MAX_BANKS: usize = 16; -pub const MAX_PATTERNS: usize = 16; +pub const MAX_BANKS: usize = 32; +pub const MAX_PATTERNS: usize = 32; pub const MAX_STEPS: usize = 128; pub const DEFAULT_LENGTH: usize = 16; pub use file::{load, save, FileError}; -pub use project::{Bank, Pattern, PatternSpeed, Project, Step}; +pub use project::{Bank, LaunchQuantization, Pattern, PatternSpeed, Project, Step, SyncMode}; diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 0f8943c..ae97746 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::{DEFAULT_LENGTH, MAX_BANKS, MAX_PATTERNS, MAX_STEPS}; -#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq)] +#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] pub enum PatternSpeed { Eighth, // 1/8x Quarter, // 1/4x @@ -79,6 +79,75 @@ impl PatternSpeed { } } +#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +pub enum LaunchQuantization { + Immediate, + Beat, + #[default] + Bar, + Bars2, + Bars4, + Bars8, +} + +impl LaunchQuantization { + pub fn label(&self) -> &'static str { + match self { + Self::Immediate => "Immediate", + Self::Beat => "Beat", + Self::Bar => "1 Bar", + Self::Bars2 => "2 Bars", + Self::Bars4 => "4 Bars", + Self::Bars8 => "8 Bars", + } + } + + pub fn next(&self) -> Self { + match self { + Self::Immediate => Self::Beat, + Self::Beat => Self::Bar, + Self::Bar => Self::Bars2, + Self::Bars2 => Self::Bars4, + Self::Bars4 => Self::Bars8, + Self::Bars8 => Self::Bars8, + } + } + + pub fn prev(&self) -> Self { + match self { + Self::Immediate => Self::Immediate, + Self::Beat => Self::Immediate, + Self::Bar => Self::Beat, + Self::Bars2 => Self::Bar, + Self::Bars4 => Self::Bars2, + Self::Bars8 => Self::Bars4, + } + } +} + +#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +pub enum SyncMode { + #[default] + Reset, + PhaseLock, +} + +impl SyncMode { + pub fn label(&self) -> &'static str { + match self { + Self::Reset => "Reset", + Self::PhaseLock => "Phase-Lock", + } + } + + pub fn toggle(&self) -> Self { + match self { + Self::Reset => Self::PhaseLock, + Self::PhaseLock => Self::Reset, + } + } +} + #[derive(Clone, Serialize, Deserialize)] pub struct Step { pub active: bool, @@ -108,6 +177,10 @@ pub struct Pattern { pub speed: PatternSpeed, #[serde(default)] pub name: Option, + #[serde(default)] + pub quantization: LaunchQuantization, + #[serde(default)] + pub sync_mode: SyncMode, } impl Default for Pattern { @@ -117,6 +190,8 @@ impl Default for Pattern { length: DEFAULT_LENGTH, speed: PatternSpeed::default(), name: None, + quantization: LaunchQuantization::default(), + sync_mode: SyncMode::default(), } } } diff --git a/crates/ratatui/src/editor.rs b/crates/ratatui/src/editor.rs index 99eaa9d..e50a883 100644 --- a/crates/ratatui/src/editor.rs +++ b/crates/ratatui/src/editor.rs @@ -60,6 +60,40 @@ pub struct Editor { search: SearchState, } +impl Editor { + pub fn start_selection(&mut self) { + self.text.start_selection(); + } + + pub fn cancel_selection(&mut self) { + self.text.cancel_selection(); + } + + pub fn is_selecting(&self) -> bool { + self.text.is_selecting() + } + + pub fn copy(&mut self) { + self.text.copy(); + } + + pub fn cut(&mut self) -> bool { + self.text.cut() + } + + pub fn paste(&mut self) -> bool { + self.text.paste() + } + + pub fn select_all(&mut self) { + self.text.select_all(); + } + + pub fn selection_range(&self) -> Option<((usize, usize), (usize, usize))> { + self.text.selection_range() + } +} + impl Default for Editor { fn default() -> Self { Self::new() @@ -300,6 +334,9 @@ impl Editor { pub fn render(&self, frame: &mut Frame, area: Rect, highlighter: Highlighter) { let (cursor_row, cursor_col) = self.text.cursor(); let cursor_style = Style::default().bg(Color::White).fg(Color::Black); + let selection_style = Style::default().bg(Color::Rgb(60, 80, 120)); + + let selection = self.text.selection_range(); let lines: Vec = self .text @@ -309,39 +346,30 @@ impl Editor { .map(|(row, line)| { let tokens = highlighter(row, line); let mut spans: Vec = Vec::new(); + let mut col = 0; - 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::(); + for (base_style, text) in tokens { + for ch in text.chars() { + let is_cursor = row == cursor_row && col == cursor_col; + let is_selected = is_in_selection(row, col, selection); - if !before.is_empty() { - spans.push(Span::styled(before, style)); - } - spans.push(Span::styled(cursor_char.to_string(), cursor_style)); - if !after.is_empty() { - spans.push(Span::styled(after, style)); - } + let style = if is_cursor { + cursor_style + } else if is_selected { + base_style.bg(selection_style.bg.unwrap()) } else { - spans.push(Span::styled(text, style)); - } - col += text_len; - } - if cursor_col >= col { - spans.push(Span::styled(" ", cursor_style)); - } - } else { - for (style, text) in tokens { - spans.push(Span::styled(text, style)); + base_style + }; + + spans.push(Span::styled(ch.to_string(), style)); + col += 1; } } + if row == cursor_row && cursor_col >= col { + spans.push(Span::styled(" ", cursor_style)); + } + Line::from(spans) }) .collect(); @@ -468,3 +496,23 @@ impl Editor { fn is_word_char(c: char) -> bool { c.is_alphanumeric() || matches!(c, '!' | '@' | '?' | '.' | ':' | '_' | '#') } + +fn is_in_selection(row: usize, col: usize, selection: Option<((usize, usize), (usize, usize))>) -> bool { + let Some(((start_row, start_col), (end_row, end_col))) = selection else { + return false; + }; + + if row < start_row || row > end_row { + return false; + } + + if row == start_row && row == end_row { + col >= start_col && col < end_col + } else if row == start_row { + col >= start_col + } else if row == end_row { + col < end_col + } else { + true + } +} diff --git a/src/app.rs b/src/app.rs index c057e65..fe36ad7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,8 @@ use crate::services::pattern_editor; use crate::settings::Settings; use crate::state::{ AudioSettings, DictFocus, EditorContext, FlashKind, Focus, LiveKeyState, Metrics, Modal, - OptionsState, PanelState, PatternField, PatternsNav, PlaybackState, ProjectState, UiState, + OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, PlaybackState, + ProjectState, StagedChange, UiState, }; use crate::views::{dict_view, help_view}; @@ -399,48 +400,76 @@ impl App { } } - pub fn toggle_pattern_playback( + pub fn stage_pattern_toggle( &mut self, bank: usize, pattern: usize, snapshot: &SequencerSnapshot, ) { let is_playing = snapshot.is_playing(bank, pattern); + let pattern_data = self.project_state.project.pattern_at(bank, pattern); - let pending = self + let existing = self .playback - .queued_changes + .staged_changes .iter() - .position(|c| c.pattern_id().bank == bank && c.pattern_id().pattern == pattern); + .position(|c| { + c.change.pattern_id().bank == bank && c.change.pattern_id().pattern == pattern + }); - if let Some(idx) = pending { - self.playback.queued_changes.remove(idx); + if let Some(idx) = existing { + self.playback.staged_changes.remove(idx); self.ui.set_status(format!( - "B{:02}:P{:02} change cancelled", + "B{:02}:P{:02} unstaged", bank + 1, pattern + 1 )); } else if is_playing { - self.playback - .queued_changes - .push(PatternChange::Stop { bank, pattern }); + self.playback.staged_changes.push(StagedChange { + change: PatternChange::Stop { bank, pattern }, + quantization: pattern_data.quantization, + sync_mode: pattern_data.sync_mode, + }); self.ui.set_status(format!( - "B{:02}:P{:02} queued to stop", + "B{:02}:P{:02} staged to stop", bank + 1, pattern + 1 )); } else { - self.playback - .queued_changes - .push(PatternChange::Start { bank, pattern }); + self.playback.staged_changes.push(StagedChange { + change: PatternChange::Start { bank, pattern }, + quantization: pattern_data.quantization, + sync_mode: pattern_data.sync_mode, + }); self.ui.set_status(format!( - "B{:02}:P{:02} queued to play", + "B{:02}:P{:02} staged to play", bank + 1, pattern + 1 )); } } + pub fn commit_staged_changes(&mut self) { + if self.playback.staged_changes.is_empty() { + self.ui.set_status("No changes to commit".to_string()); + return; + } + let count = self.playback.staged_changes.len(); + self.playback + .queued_changes + .append(&mut self.playback.staged_changes); + self.ui.set_status(format!("Committed {count} changes")); + } + + pub fn clear_staged_changes(&mut self) { + if self.playback.staged_changes.is_empty() { + return; + } + let count = self.playback.staged_changes.len(); + self.playback.staged_changes.clear(); + self.ui.set_status(format!("Cleared {count} staged changes")); + } + pub fn select_edit_pattern(&mut self, pattern: usize) { self.editor_ctx.pattern = pattern; self.editor_ctx.step = 0; @@ -724,6 +753,20 @@ impl App { }; } + pub fn open_pattern_props_modal(&mut self, bank: usize, pattern: usize) { + let pat = self.project_state.project.pattern_at(bank, pattern); + self.ui.modal = Modal::PatternProps { + bank, + pattern, + field: PatternPropsField::default(), + name: pat.name.clone().unwrap_or_default(), + length: pat.length.to_string(), + speed: pat.speed, + quantization: pat.quantization, + sync_mode: pat.sync_mode, + }; + } + pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) { match cmd { // Playback @@ -815,12 +858,15 @@ impl App { AppCommand::LinkPasteStep => self.link_paste_step(), AppCommand::HardenStep => self.harden_step(), - // Pattern playback - AppCommand::QueuePatternChange(change) => { - self.playback.queued_changes.push(change); + // Pattern playback (staging) + AppCommand::StagePatternToggle { bank, pattern } => { + self.stage_pattern_toggle(bank, pattern, snapshot); } - AppCommand::TogglePatternPlayback { bank, pattern } => { - self.toggle_pattern_playback(bank, pattern, snapshot); + AppCommand::CommitStagedChanges => { + self.commit_staged_changes(); + } + AppCommand::ClearStagedChanges => { + self.clear_staged_changes(); } // Project @@ -859,6 +905,28 @@ impl App { } AppCommand::CloseModal => self.ui.modal = Modal::None, AppCommand::OpenPatternModal(field) => self.open_pattern_modal(field), + AppCommand::OpenPatternPropsModal { bank, pattern } => { + self.open_pattern_props_modal(bank, pattern); + } + AppCommand::SetPatternProps { + bank, + pattern, + name, + length, + speed, + quantization, + sync_mode, + } => { + let pat = self.project_state.project.pattern_at_mut(bank, pattern); + pat.name = name; + if let Some(len) = length { + pat.set_length(len); + } + pat.speed = speed; + pat.quantization = quantization; + pat.sync_mode = sync_mode; + self.project_state.mark_dirty(bank, pattern); + } // Page navigation AppCommand::PageLeft => self.page.left(), @@ -953,19 +1021,28 @@ impl App { AppCommand::PatternsTogglePlay => { let bank = self.patterns_nav.selected_bank(); let pattern = self.patterns_nav.selected_pattern(); - self.toggle_pattern_playback(bank, pattern, snapshot); + self.stage_pattern_toggle(bank, pattern, snapshot); } } } pub fn flush_queued_changes(&mut self, cmd_tx: &Sender) { - for change in self.playback.queued_changes.drain(..) { - match change { + for staged in self.playback.queued_changes.drain(..) { + match staged.change { PatternChange::Start { bank, pattern } => { - let _ = cmd_tx.send(SeqCommand::PatternStart { bank, pattern }); + let _ = cmd_tx.send(SeqCommand::PatternStart { + bank, + pattern, + quantization: staged.quantization, + sync_mode: staged.sync_mode, + }); } PatternChange::Stop { bank, pattern } => { - let _ = cmd_tx.send(SeqCommand::PatternStop { bank, pattern }); + let _ = cmd_tx.send(SeqCommand::PatternStop { + bank, + pattern, + quantization: staged.quantization, + }); } } } @@ -987,6 +1064,8 @@ impl App { source: s.source, }) .collect(), + quantization: pat.quantization, + sync_mode: pat.sync_mode, }; let _ = cmd_tx.send(SeqCommand::PatternUpdate { bank, diff --git a/src/commands.rs b/src/commands.rs index 0c710a8..6364e05 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,7 +1,6 @@ use std::path::PathBuf; -use crate::engine::PatternChange; -use crate::model::PatternSpeed; +use crate::model::{LaunchQuantization, PatternSpeed, SyncMode}; use crate::state::{FlashKind, Modal, PatternField}; #[allow(dead_code)] @@ -74,12 +73,13 @@ pub enum AppCommand { LinkPasteStep, HardenStep, - // Pattern playback - QueuePatternChange(PatternChange), - TogglePatternPlayback { + // Pattern playback (staging) + StagePatternToggle { bank: usize, pattern: usize, }, + CommitStagedChanges, + ClearStagedChanges, // Project RenameBank { @@ -105,6 +105,19 @@ pub enum AppCommand { OpenModal(Modal), CloseModal, OpenPatternModal(PatternField), + OpenPatternPropsModal { + bank: usize, + pattern: usize, + }, + SetPatternProps { + bank: usize, + pattern: usize, + name: Option, + length: Option, + speed: PatternSpeed, + quantization: LaunchQuantization, + sync_mode: SyncMode, + }, // Page navigation PageLeft, diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 5cbba13..f36363a 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -7,7 +7,7 @@ use std::time::Duration; use thread_priority::{set_current_thread_priority, ThreadPriority}; use super::LinkState; -use crate::model::{MAX_BANKS, MAX_PATTERNS}; +use crate::model::{LaunchQuantization, SyncMode, MAX_BANKS, MAX_PATTERNS}; use crate::model::{Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables}; use crate::state::LiveKeyState; @@ -56,10 +56,13 @@ pub enum SeqCommand { PatternStart { bank: usize, pattern: usize, + quantization: LaunchQuantization, + sync_mode: SyncMode, }, PatternStop { bank: usize, pattern: usize, + quantization: LaunchQuantization, }, Shutdown, } @@ -69,6 +72,8 @@ pub struct PatternSnapshot { pub speed: crate::model::PatternSpeed, pub length: usize, pub steps: Vec, + pub quantization: LaunchQuantization, + pub sync_mode: SyncMode, } #[derive(Clone)] @@ -165,11 +170,18 @@ struct ActivePattern { iter: usize, } +#[derive(Clone, Copy)] +struct PendingPattern { + id: PatternId, + quantization: LaunchQuantization, + sync_mode: SyncMode, +} + struct AudioState { prev_beat: f64, active_patterns: HashMap, - pending_starts: Vec, - pending_stops: Vec, + pending_starts: Vec, + pending_stops: Vec, } impl AudioState { @@ -275,6 +287,41 @@ impl PatternSnapshot { } } +fn check_quantization_boundary( + quantization: LaunchQuantization, + beat: f64, + prev_beat: f64, + quantum: f64, +) -> bool { + if prev_beat < 0.0 { + return false; + } + match quantization { + LaunchQuantization::Immediate => true, + LaunchQuantization::Beat => beat.floor() as i64 != prev_beat.floor() as i64, + LaunchQuantization::Bar => { + let bar = (beat / quantum).floor() as i64; + let prev_bar = (prev_beat / quantum).floor() as i64; + bar != prev_bar + } + LaunchQuantization::Bars2 => { + let bars2 = (beat / (quantum * 2.0)).floor() as i64; + let prev_bars2 = (prev_beat / (quantum * 2.0)).floor() as i64; + bars2 != prev_bars2 + } + LaunchQuantization::Bars4 => { + let bars4 = (beat / (quantum * 4.0)).floor() as i64; + let prev_bars4 = (prev_beat / (quantum * 4.0)).floor() as i64; + bars4 != prev_bars4 + } + LaunchQuantization::Bars8 => { + let bars8 = (beat / (quantum * 8.0)).floor() as i64; + let prev_bars8 = (prev_beat / (quantum * 8.0)).floor() as i64; + bars8 != prev_bars8 + } + } +} + type StepKey = (usize, usize, usize); struct RunsCounter { @@ -332,18 +379,35 @@ fn sequencer_loop( } => { pattern_cache.set(bank, pattern, data); } - SeqCommand::PatternStart { bank, pattern } => { + SeqCommand::PatternStart { + bank, + pattern, + quantization, + sync_mode, + } => { let id = PatternId { bank, pattern }; - audio_state.pending_stops.retain(|p| *p != id); - if !audio_state.pending_starts.contains(&id) { - audio_state.pending_starts.push(id); + audio_state.pending_stops.retain(|p| p.id != id); + if !audio_state.pending_starts.iter().any(|p| p.id == id) { + audio_state.pending_starts.push(PendingPattern { + id, + quantization, + sync_mode, + }); } } - SeqCommand::PatternStop { bank, pattern } => { + SeqCommand::PatternStop { + bank, + pattern, + quantization, + } => { let id = PatternId { bank, pattern }; - audio_state.pending_starts.retain(|p| *p != id); - if !audio_state.pending_stops.contains(&id) { - audio_state.pending_stops.push(id); + audio_state.pending_starts.retain(|p| p.id != id); + if !audio_state.pending_stops.iter().any(|p| p.id == id) { + audio_state.pending_stops.push(PendingPattern { + id, + quantization, + sync_mode: SyncMode::Reset, + }); } } SeqCommand::Shutdown => { @@ -362,31 +426,67 @@ fn sequencer_loop( let beat = state.beat_at_time(time, quantum); let tempo = state.tempo(); - let bar = (beat / quantum).floor() as i64; - let prev_bar = (audio_state.prev_beat / quantum).floor() as i64; + let prev_beat = audio_state.prev_beat; let mut stopped_chain_keys: Vec = Vec::new(); - if bar != prev_bar && audio_state.prev_beat >= 0.0 { - for id in audio_state.pending_starts.drain(..) { + + // Process pending starts with per-pattern quantization + let mut started_ids: Vec = Vec::new(); + for pending in &audio_state.pending_starts { + let should_start = check_quantization_boundary( + pending.quantization, + beat, + prev_beat, + quantum, + ); + if should_start { + let start_step = match pending.sync_mode { + SyncMode::Reset => 0, + SyncMode::PhaseLock => { + if let Some(pat) = pattern_cache.get(pending.id.bank, pending.id.pattern) { + let speed_mult = pat.speed.multiplier(); + ((beat * 4.0 * speed_mult) as usize) % pat.length + } else { + 0 + } + } + }; audio_state.active_patterns.insert( - id, + pending.id, ActivePattern { - bank: id.bank, - pattern: id.pattern, - step_index: 0, + bank: pending.id.bank, + pattern: pending.id.pattern, + step_index: start_step, iter: 0, }, ); - } - for id in audio_state.pending_stops.drain(..) { - audio_state.active_patterns.remove(&id); - step_traces.retain(|&(bank, pattern, _), _| { - bank != id.bank || pattern != id.pattern - }); - stopped_chain_keys.push(format!("__chain_{}_{}__", id.bank, id.pattern)); + started_ids.push(pending.id); } } + audio_state.pending_starts.retain(|p| !started_ids.contains(&p.id)); + + // Process pending stops with per-pattern quantization + let mut stopped_ids: Vec = Vec::new(); + for pending in &audio_state.pending_stops { + let should_stop = check_quantization_boundary( + pending.quantization, + beat, + prev_beat, + quantum, + ); + if should_stop { + audio_state.active_patterns.remove(&pending.id); + step_traces.retain(|&(bank, pattern, _), _| { + bank != pending.id.bank || pattern != pending.id.pattern + }); + stopped_chain_keys.push(format!( + "__chain_{}_{}__", + pending.id.bank, pending.id.pattern + )); + stopped_ids.push(pending.id); + } + } + audio_state.pending_stops.retain(|p| !stopped_ids.contains(&p.id)); - let prev_beat = audio_state.prev_beat; let mut chain_transitions: Vec<(PatternId, PatternId)> = Vec::new(); let mut chain_keys_to_remove: Vec = Vec::new(); let mut new_tempo: Option = None; @@ -515,13 +615,25 @@ fn sequencer_loop( link.set_tempo(t); } - // Apply chain transitions + // Apply chain transitions (use Bar quantization for chains) for (source, target) in chain_transitions { - if !audio_state.pending_stops.contains(&source) { - audio_state.pending_stops.push(source); + if !audio_state.pending_stops.iter().any(|p| p.id == source) { + audio_state.pending_stops.push(PendingPattern { + id: source, + quantization: LaunchQuantization::Bar, + sync_mode: SyncMode::Reset, + }); } - if !audio_state.pending_starts.contains(&target) { - audio_state.pending_starts.push(target); + if !audio_state.pending_starts.iter().any(|p| p.id == target) { + let (quant, sync) = pattern_cache + .get(target.bank, target.pattern) + .map(|p| (p.quantization, p.sync_mode)) + .unwrap_or((LaunchQuantization::Bar, SyncMode::Reset)); + audio_state.pending_starts.push(PendingPattern { + id: target, + quantization: quant, + sync_mode: sync, + }); } } diff --git a/src/input.rs b/src/input.rs index 1d2b543..110ac53 100644 --- a/src/input.rs +++ b/src/input.rs @@ -10,7 +10,10 @@ use crate::commands::AppCommand; use crate::engine::{AudioCommand, LinkState, SequencerSnapshot}; use crate::model::PatternSpeed; use crate::page::Page; -use crate::state::{DeviceKind, EngineSection, Modal, OptionsFocus, PanelFocus, PatternField, SampleBrowserState, SettingKind, SidePanel}; +use crate::state::{ + DeviceKind, EngineSection, Modal, OptionsFocus, PanelFocus, PatternField, PatternPropsField, + SampleBrowserState, SettingKind, SidePanel, +}; pub enum InputResult { Continue, @@ -385,6 +388,7 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { }, Modal::Editor => { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let shift = key.modifiers.contains(KeyModifiers::SHIFT); let editor = &mut ctx.app.editor_ctx.editor; if editor.search_active() { @@ -400,7 +404,9 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match key.code { KeyCode::Esc => { - if editor.completion_active() { + if editor.is_selecting() { + editor.cancel_selection(); + } else if editor.completion_active() { editor.dismiss_completion(); } else { ctx.dispatch(AppCommand::SaveEditorToStep); @@ -421,6 +427,24 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { KeyCode::Char('p') if ctrl => { editor.search_prev(); } + KeyCode::Char('a') if ctrl => { + editor.select_all(); + } + KeyCode::Char('c') if ctrl => { + editor.copy(); + } + KeyCode::Char('x') if ctrl => { + editor.cut(); + } + KeyCode::Char('v') if ctrl => { + editor.paste(); + } + KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down if shift => { + if !editor.is_selecting() { + editor.start_selection(); + } + editor.input(Event::Key(key)); + } _ => { editor.input(Event::Key(key)); } @@ -434,6 +458,71 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { KeyCode::Down => ctx.dispatch(AppCommand::StepDown), _ => {} }, + Modal::PatternProps { + bank, + pattern, + field, + name, + length, + speed, + quantization, + sync_mode, + } => { + let (bank, pattern) = (*bank, *pattern); + match key.code { + KeyCode::Up => *field = field.prev(), + KeyCode::Down | KeyCode::Tab => *field = field.next(), + KeyCode::Left => match field { + PatternPropsField::Speed => *speed = speed.prev(), + PatternPropsField::Quantization => *quantization = quantization.prev(), + PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(), + _ => {} + }, + KeyCode::Right => match field { + PatternPropsField::Speed => *speed = speed.next(), + PatternPropsField::Quantization => *quantization = quantization.next(), + PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(), + _ => {} + }, + KeyCode::Char(c) => match field { + PatternPropsField::Name => name.push(c), + PatternPropsField::Length if c.is_ascii_digit() => length.push(c), + _ => {} + }, + KeyCode::Backspace => match field { + PatternPropsField::Name => { + name.pop(); + } + PatternPropsField::Length => { + length.pop(); + } + _ => {} + }, + KeyCode::Enter => { + let name_val = if name.is_empty() { + None + } else { + Some(name.clone()) + }; + let length_val = length.parse().ok(); + let speed_val = *speed; + let quant_val = *quantization; + let sync_val = *sync_mode; + ctx.dispatch(AppCommand::SetPatternProps { + bank, + pattern, + name: name_val, + length: length_val, + speed: speed_val, + quantization: quant_val, + sync_mode: sync_val, + }); + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), + _ => {} + } + } Modal::None => unreachable!(), } InputResult::Continue @@ -660,9 +749,16 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { KeyCode::Right => ctx.dispatch(AppCommand::PatternsCursorRight), KeyCode::Up => ctx.dispatch(AppCommand::PatternsCursorUp), KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown), - KeyCode::Esc => ctx.dispatch(AppCommand::PatternsBack), + KeyCode::Esc => { + if !ctx.app.playback.staged_changes.is_empty() { + ctx.dispatch(AppCommand::ClearStagedChanges); + } else { + ctx.dispatch(AppCommand::PatternsBack); + } + } KeyCode::Enter => ctx.dispatch(AppCommand::PatternsEnter), KeyCode::Char(' ') => ctx.dispatch(AppCommand::PatternsTogglePlay), + KeyCode::Char('c') if !ctrl => ctx.dispatch(AppCommand::CommitStagedChanges), KeyCode::Char('q') => { ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { selected: false, @@ -738,6 +834,13 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } } } + KeyCode::Char('e') if !ctrl => { + if ctx.app.patterns_nav.column == PatternsColumn::Patterns { + let bank = ctx.app.patterns_nav.bank_cursor; + let pattern = ctx.app.patterns_nav.pattern_cursor; + ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern }); + } + } _ => {} } InputResult::Continue diff --git a/src/main.rs b/src/main.rs index 771be52..bd2e963 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,9 +71,13 @@ fn main() -> io::Result<()> { app.playback .queued_changes - .push(engine::PatternChange::Start { - bank: 0, - pattern: 0, + .push(crate::state::StagedChange { + change: engine::PatternChange::Start { + bank: 0, + pattern: 0, + }, + quantization: crate::model::LaunchQuantization::Immediate, + sync_mode: crate::model::SyncMode::Reset, }); app.audio.config.output_device = args.output.or(settings.audio.output_device); diff --git a/src/model/mod.rs b/src/model/mod.rs index ccce919..bd68f45 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,5 +1,8 @@ mod script; pub use cagire_forth::{Word, WordCompile, WORDS}; -pub use cagire_project::{load, save, Bank, Pattern, PatternSpeed, Project, MAX_BANKS, MAX_PATTERNS}; +pub use cagire_project::{ + load, save, Bank, LaunchQuantization, Pattern, PatternSpeed, Project, SyncMode, MAX_BANKS, + MAX_PATTERNS, +}; pub use script::{Dictionary, ExecutionTrace, Rng, ScriptEngine, SourceSpan, StepContext, Value, Variables}; diff --git a/src/state/editor.rs b/src/state/editor.rs index bb50e6e..2855569 100644 --- a/src/state/editor.rs +++ b/src/state/editor.rs @@ -12,6 +12,38 @@ pub enum PatternField { Speed, } +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum PatternPropsField { + #[default] + Name, + Length, + Speed, + Quantization, + SyncMode, +} + +impl PatternPropsField { + pub fn next(&self) -> Self { + match self { + Self::Name => Self::Length, + Self::Length => Self::Speed, + Self::Speed => Self::Quantization, + Self::Quantization => Self::SyncMode, + Self::SyncMode => Self::SyncMode, + } + } + + pub fn prev(&self) -> Self { + match self { + Self::Name => Self::Name, + Self::Length => Self::Name, + Self::Speed => Self::Length, + Self::Quantization => Self::Speed, + Self::SyncMode => Self::Quantization, + } + } +} + pub struct EditorContext { pub bank: usize, pub pattern: usize, diff --git a/src/state/mod.rs b/src/state/mod.rs index 6a9db9c..f27647a 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -13,12 +13,12 @@ pub mod ui; pub use audio::{AudioSettings, DeviceKind, EngineSection, Metrics, SettingKind}; pub use options::{OptionsFocus, OptionsState}; -pub use editor::{CopiedStep, EditorContext, Focus, PatternField}; +pub use editor::{CopiedStep, EditorContext, Focus, PatternField, PatternPropsField}; pub use live_keys::LiveKeyState; pub use modal::Modal; pub use panel::{PanelFocus, PanelState, SidePanel}; pub use patterns_nav::{PatternsColumn, PatternsNav}; -pub use playback::PlaybackState; +pub use playback::{PlaybackState, StagedChange}; pub use project::ProjectState; pub use sample_browser::SampleBrowserState; pub use ui::{DictFocus, FlashKind, UiState}; diff --git a/src/state/modal.rs b/src/state/modal.rs index 4f8c44c..60faf96 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -1,4 +1,5 @@ -use crate::state::editor::PatternField; +use crate::model::{LaunchQuantization, PatternSpeed, SyncMode}; +use crate::state::editor::{PatternField, PatternPropsField}; use crate::state::file_browser::FileBrowserState; #[derive(Clone, PartialEq, Eq)] @@ -40,4 +41,14 @@ pub enum Modal { AddSamplePath(FileBrowserState), Editor, Preview, + PatternProps { + bank: usize, + pattern: usize, + field: PatternPropsField, + name: String, + length: String, + speed: PatternSpeed, + quantization: LaunchQuantization, + sync_mode: SyncMode, + }, } diff --git a/src/state/patterns_nav.rs b/src/state/patterns_nav.rs index d2676cb..1df7053 100644 --- a/src/state/patterns_nav.rs +++ b/src/state/patterns_nav.rs @@ -1,3 +1,5 @@ +use crate::model::{MAX_BANKS, MAX_PATTERNS}; + #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum PatternsColumn { #[default] @@ -24,10 +26,10 @@ impl PatternsNav { pub fn move_up(&mut self) { match self.column { PatternsColumn::Banks => { - self.bank_cursor = (self.bank_cursor + 15) % 16; + self.bank_cursor = (self.bank_cursor + MAX_BANKS - 1) % MAX_BANKS; } PatternsColumn::Patterns => { - self.pattern_cursor = (self.pattern_cursor + 15) % 16; + self.pattern_cursor = (self.pattern_cursor + MAX_PATTERNS - 1) % MAX_PATTERNS; } } } @@ -35,10 +37,10 @@ impl PatternsNav { pub fn move_down(&mut self) { match self.column { PatternsColumn::Banks => { - self.bank_cursor = (self.bank_cursor + 1) % 16; + self.bank_cursor = (self.bank_cursor + 1) % MAX_BANKS; } PatternsColumn::Patterns => { - self.pattern_cursor = (self.pattern_cursor + 1) % 16; + self.pattern_cursor = (self.pattern_cursor + 1) % MAX_PATTERNS; } } } diff --git a/src/state/playback.rs b/src/state/playback.rs index 4d6eca1..6afef2b 100644 --- a/src/state/playback.rs +++ b/src/state/playback.rs @@ -1,14 +1,24 @@ use crate::engine::PatternChange; +use crate::model::{LaunchQuantization, SyncMode}; + +#[derive(Clone)] +pub struct StagedChange { + pub change: PatternChange, + pub quantization: LaunchQuantization, + pub sync_mode: SyncMode, +} pub struct PlaybackState { pub playing: bool, - pub queued_changes: Vec, + pub staged_changes: Vec, + pub queued_changes: Vec, } impl Default for PlaybackState { fn default() -> Self { Self { playing: true, + staged_changes: Vec::new(), queued_changes: Vec::new(), } } diff --git a/src/views/patterns_view.rs b/src/views/patterns_view.rs index 965e066..dea8a93 100644 --- a/src/views/patterns_view.rs +++ b/src/views/patterns_view.rs @@ -6,6 +6,7 @@ use ratatui::Frame; use crate::app::App; use crate::engine::SequencerSnapshot; +use crate::model::{MAX_BANKS, MAX_PATTERNS}; use crate::state::PatternsColumn; pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { @@ -44,25 +45,25 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area .map(|p| p.bank) .collect(); - let banks_with_queued: Vec = app + let banks_with_staged: Vec = app .playback - .queued_changes + .staged_changes .iter() - .filter_map(|c| match c { + .filter_map(|c| match &c.change { crate::engine::PatternChange::Start { bank, .. } => Some(*bank), _ => None, }) .collect(); - let row_height = (inner.height / 16).max(1); - let total_needed = row_height * 16; + let row_height = (inner.height / MAX_BANKS as u16).max(1); + let total_needed = row_height * MAX_BANKS as u16; let top_padding = if inner.height > total_needed { (inner.height - total_needed) / 2 } else { 0 }; - for idx in 0..16 { + for idx in 0..MAX_BANKS { let y = inner.y + top_padding + (idx as u16) * row_height; if y >= inner.y + inner.height { break; @@ -79,12 +80,12 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area 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 is_staged = banks_with_staged.contains(&idx); - let (bg, fg, prefix) = match (is_cursor, is_playing, is_queued) { + let (bg, fg, prefix) = match (is_cursor, is_playing, is_staged) { (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, true) => (Color::Rgb(80, 60, 100), Color::Magenta, "+ "), (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), ""), @@ -101,7 +102,7 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area }; let style = Style::new().bg(bg).fg(fg); - let style = if is_playing || is_queued { + let style = if is_playing || is_staged { style.add_modifier(Modifier::BOLD) } else { style @@ -159,11 +160,11 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a .map(|p| p.pattern) .collect(); - let queued_to_play: Vec = app + let staged_to_play: Vec = app .playback - .queued_changes + .staged_changes .iter() - .filter_map(|c| match c { + .filter_map(|c| match &c.change { crate::engine::PatternChange::Start { bank: b, pattern, .. } if *b == bank => Some(*pattern), @@ -171,11 +172,11 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a }) .collect(); - let queued_to_stop: Vec = app + let staged_to_stop: Vec = app .playback - .queued_changes + .staged_changes .iter() - .filter_map(|c| match c { + .filter_map(|c| match &c.change { crate::engine::PatternChange::Stop { bank: b, pattern, @@ -190,15 +191,15 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a None }; - let row_height = (inner.height / 16).max(1); - let total_needed = row_height * 16; + let row_height = (inner.height / MAX_PATTERNS as u16).max(1); + let total_needed = row_height * MAX_PATTERNS as u16; let top_padding = if inner.height > total_needed { (inner.height - total_needed) / 2 } else { 0 }; - for idx in 0..16 { + for idx in 0..MAX_PATTERNS { let y = inner.y + top_padding + (idx as u16) * row_height; if y >= inner.y + inner.height { break; @@ -215,14 +216,14 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a 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 is_staged_play = staged_to_play.contains(&idx); + let is_staged_stop = staged_to_stop.contains(&idx); - let (bg, fg, prefix) = match (is_cursor, is_playing, is_queued_play, is_queued_stop) { + let (bg, fg, prefix) = match (is_cursor, is_playing, is_staged_play, is_staged_stop) { (true, _, _, _) => (Color::Cyan, Color::Black, ""), - (false, true, _, true) => (Color::Rgb(120, 90, 30), Color::Yellow, "x "), + (false, true, _, true) => (Color::Rgb(120, 60, 80), Color::Magenta, "- "), (false, true, _, false) => (Color::Rgb(45, 80, 45), Color::Green, "> "), - (false, false, true, _) => (Color::Rgb(80, 80, 45), Color::Yellow, "? "), + (false, false, true, _) => (Color::Rgb(80, 60, 100), Color::Magenta, "+ "), (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), ""), @@ -271,7 +272,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a } else { format!("{}{:02} {}", prefix, idx + 1, name) }; - let name_style = if is_playing || is_queued_play { + let name_style = if is_playing || is_staged_play { bold_style } else { base_style diff --git a/src/views/render.rs b/src/views/render.rs index 4bf5286..411de7d 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -3,7 +3,7 @@ use std::time::Instant; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::Frame; use crate::app::App; @@ -626,5 +626,91 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term }; frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area); } + Modal::PatternProps { + bank, + pattern, + field, + name, + length, + speed, + quantization, + sync_mode, + } => { + use crate::state::PatternPropsField; + + let width = 50u16; + let height = 12u16; + let x = (term.width.saturating_sub(width)) / 2; + let y = (term.height.saturating_sub(height)) / 2; + let area = Rect::new(x, y, width, height); + + let block = Block::bordered() + .title(format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1)) + .border_style(Style::default().fg(Color::Cyan)); + + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + + let fields = [ + ("Name", name.as_str(), *field == PatternPropsField::Name), + ("Length", length.as_str(), *field == PatternPropsField::Length), + ("Speed", speed.label(), *field == PatternPropsField::Speed), + ( + "Quantization", + quantization.label(), + *field == PatternPropsField::Quantization, + ), + ( + "Sync Mode", + sync_mode.label(), + *field == PatternPropsField::SyncMode, + ), + ]; + + for (i, (label, value, selected)) in fields.iter().enumerate() { + let y = inner.y + i as u16; + if y >= inner.y + inner.height { + break; + } + + let (label_style, value_style) = if *selected { + ( + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + Style::default().fg(Color::White).bg(Color::DarkGray), + ) + } else { + ( + Style::default().fg(Color::Gray), + Style::default().fg(Color::White), + ) + }; + + let label_area = Rect::new(inner.x + 1, y, 14, 1); + let value_area = Rect::new(inner.x + 16, y, inner.width.saturating_sub(18), 1); + + frame.render_widget( + Paragraph::new(format!("{label}:")).style(label_style), + label_area, + ); + frame.render_widget( + Paragraph::new(*value).style(value_style), + value_area, + ); + } + + let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); + let hint = Line::from(vec![ + Span::styled("↑↓", Style::default().fg(Color::Yellow)), + Span::styled(" nav ", Style::default().fg(Color::DarkGray)), + Span::styled("←→", Style::default().fg(Color::Yellow)), + Span::styled(" change ", Style::default().fg(Color::DarkGray)), + Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::styled(" save ", Style::default().fg(Color::DarkGray)), + Span::styled("Esc", Style::default().fg(Color::Yellow)), + Span::styled(" cancel", Style::default().fg(Color::DarkGray)), + ]); + frame.render_widget(Paragraph::new(hint), hint_area); + } } }