diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index af0cad5..c9e6a27 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -94,7 +94,6 @@ pub enum Op { Triangle, Range, Perlin, - Chain, Loop, Degree(&'static [i64]), Oct, diff --git a/crates/forth/src/types.rs b/crates/forth/src/types.rs index ba65598..27b79a1 100644 --- a/crates/forth/src/types.rs +++ b/crates/forth/src/types.rs @@ -59,7 +59,6 @@ pub struct StepContext<'a> { pub nudge_secs: f64, pub cc_access: Option<&'a dyn CcAccess>, pub speed_key: &'a str, - pub chain_key: &'a str, pub mouse_x: f64, pub mouse_y: f64, pub mouse_down: f64, diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index f3c3db9..03e1bc6 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -992,26 +992,6 @@ impl Forth { .insert(ctx.speed_key.to_string(), Value::Float(clamped, None)); } - Op::Chain => { - let pattern = pop_int(stack)? - 1; - let bank = pop_int(stack)? - 1; - if bank < 0 || pattern < 0 { - return Err("chain: bank and pattern must be >= 1".into()); - } - if bank as usize == ctx.bank && pattern as usize == ctx.pattern { - // chaining to self is a no-op - } else { - use std::fmt::Write; - let mut val = String::with_capacity(8); - let _ = write!(&mut val, "{bank}:{pattern}"); - var_writes_cell - .borrow_mut() - .as_mut() - .expect("var_writes taken") - .insert(ctx.chain_key.to_string(), Value::Str(Arc::from(val), None)); - } - } - Op::Loop => { let beats = pop_float(stack)?; if ctx.tempo == 0.0 || ctx.speed == 0.0 { diff --git a/crates/forth/src/words/compile.rs b/crates/forth/src/words/compile.rs index 8d4040c..c1ecba2 100644 --- a/crates/forth/src/words/compile.rs +++ b/crates/forth/src/words/compile.rs @@ -89,7 +89,6 @@ pub(super) fn simple_op(name: &str) -> Option { "triangle" => Op::Triangle, "range" => Op::Range, "perlin" => Op::Perlin, - "chain" => Op::Chain, "loop" => Op::Loop, "oct" => Op::Oct, "clear" => Op::ClearCmd, diff --git a/crates/forth/src/words/sequencing.rs b/crates/forth/src/words/sequencing.rs index 9dd57aa..69dee76 100644 --- a/crates/forth/src/words/sequencing.rs +++ b/crates/forth/src/words/sequencing.rs @@ -254,16 +254,6 @@ pub(super) const WORDS: &[Word] = &[ compile: Simple, varargs: false, }, - Word { - name: "chain", - aliases: &[], - category: "Time", - stack: "(bank pattern --)", - desc: "Chain to bank/pattern (1-indexed) when current pattern ends", - example: "1 4 chain", - compile: Simple, - varargs: false, - }, Word { name: "at", aliases: &[], diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 550568a..d4d4228 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -7,4 +7,4 @@ pub const MAX_STEPS: usize = 1024; pub const DEFAULT_LENGTH: usize = 16; pub use file::{load, save, FileError}; -pub use project::{Bank, LaunchQuantization, Pattern, PatternSpeed, Project, Step, SyncMode}; +pub use project::{Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed, Project, Step, SyncMode}; diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index f41c52b..819405c 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -206,6 +206,44 @@ impl SyncMode { } } +#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +pub enum FollowUp { + #[default] + Loop, + Stop, + Chain { bank: usize, pattern: usize }, +} + +impl FollowUp { + pub fn label(&self) -> &'static str { + match self { + Self::Loop => "Loop", + Self::Stop => "Stop", + Self::Chain { .. } => "Chain", + } + } + + pub fn next_mode(&self) -> Self { + match self { + Self::Loop => Self::Stop, + Self::Stop => Self::Chain { bank: 0, pattern: 0 }, + Self::Chain { .. } => Self::Loop, + } + } + + pub fn prev_mode(&self) -> Self { + match self { + Self::Loop => Self::Chain { bank: 0, pattern: 0 }, + Self::Stop => Self::Loop, + Self::Chain { .. } => Self::Stop, + } + } +} + +fn is_default_follow_up(f: &FollowUp) -> bool { + *f == FollowUp::default() +} + #[derive(Clone, Serialize, Deserialize)] pub struct Step { pub active: bool, @@ -245,6 +283,7 @@ pub struct Pattern { pub name: Option, pub quantization: LaunchQuantization, pub sync_mode: SyncMode, + pub follow_up: FollowUp, } #[derive(Serialize, Deserialize)] @@ -280,6 +319,8 @@ struct SparsePattern { quantization: LaunchQuantization, #[serde(default, skip_serializing_if = "is_default_sync_mode")] sync_mode: SyncMode, + #[serde(default, skip_serializing_if = "is_default_follow_up")] + follow_up: FollowUp, } fn is_default_quantization(q: &LaunchQuantization) -> bool { @@ -302,6 +343,8 @@ struct LegacyPattern { quantization: LaunchQuantization, #[serde(default)] sync_mode: SyncMode, + #[serde(default)] + follow_up: FollowUp, } impl Serialize for Pattern { @@ -327,6 +370,7 @@ impl Serialize for Pattern { name: self.name.clone(), quantization: self.quantization, sync_mode: self.sync_mode, + follow_up: self.follow_up, }; sparse.serialize(serializer) } @@ -361,6 +405,7 @@ impl<'de> Deserialize<'de> for Pattern { name: sparse.name, quantization: sparse.quantization, sync_mode: sparse.sync_mode, + follow_up: sparse.follow_up, }) } PatternFormat::Legacy(legacy) => Ok(Pattern { @@ -370,6 +415,7 @@ impl<'de> Deserialize<'de> for Pattern { name: legacy.name, quantization: legacy.quantization, sync_mode: legacy.sync_mode, + follow_up: legacy.follow_up, }), } } @@ -384,6 +430,7 @@ impl Default for Pattern { name: None, quantization: LaunchQuantization::default(), sync_mode: SyncMode::default(), + follow_up: FollowUp::default(), } } } diff --git a/docs/getting-started/banks_patterns.md b/docs/getting-started/banks_patterns.md index e35bf37..048a563 100644 --- a/docs/getting-started/banks_patterns.md +++ b/docs/getting-started/banks_patterns.md @@ -27,9 +27,18 @@ Each pattern is an independent sequence of steps with its own properties: | Speed | Playback rate (`1/8x` to `8x`) | `1x` | | Quantization | When the pattern launches | `Bar` | | Sync Mode | Reset or Phase-Lock on re-trigger | `Reset` | +| Follow Up | What happens when the pattern finishes an iteration | `Loop` | Press `e` in the patterns view to edit these settings. +### Follow Up + +The follow-up action determines what happens when a pattern reaches the end of its steps: + +- **Loop** — the pattern repeats indefinitely. This is the default behavior. +- **Stop** — the pattern plays once and stops. +- **Chain** — the pattern plays once, then starts another pattern. Use `Left`/`Right` to set the target bank and pattern in the edit view. + ## Patterns View Access the patterns view with `F2` (or `Ctrl+Up` from the sequencer). The view shows all banks and patterns in a grid. Indicators show pattern state: diff --git a/plugins/cagire-plugins/src/lib.rs b/plugins/cagire-plugins/src/lib.rs index ca31fe9..15ddbd7 100644 --- a/plugins/cagire-plugins/src/lib.rs +++ b/plugins/cagire-plugins/src/lib.rs @@ -217,6 +217,7 @@ impl Plugin for CagirePlugin { .collect(), quantization: pat.quantization, sync_mode: pat.sync_mode, + follow_up: pat.follow_up, }; let _ = self.bridge.cmd_tx.send(SeqCommand::PatternUpdate { bank: bank_idx, diff --git a/src/app/dispatch.rs b/src/app/dispatch.rs index ca40ada..29b0740 100644 --- a/src/app/dispatch.rs +++ b/src/app/dispatch.rs @@ -180,6 +180,7 @@ impl App { speed, quantization, sync_mode, + follow_up, } => { self.playback.staged_prop_changes.insert( (bank, pattern), @@ -189,6 +190,7 @@ impl App { speed, quantization, sync_mode, + follow_up, }, ); self.ui.set_status(format!( diff --git a/src/app/mod.rs b/src/app/mod.rs index 1838889..3fd2ab4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -189,6 +189,7 @@ impl App { speed: pat.speed, quantization: pat.quantization, sync_mode: pat.sync_mode, + follow_up: pat.follow_up, }; } diff --git a/src/app/scripting.rs b/src/app/scripting.rs index 8cdcd80..29b68c3 100644 --- a/src/app/scripting.rs +++ b/src/app/scripting.rs @@ -31,7 +31,6 @@ impl App { nudge_secs: 0.0, cc_access: None, speed_key: "", - chain_key: "", mouse_x: 0.5, mouse_y: 0.5, mouse_down: 0.0, diff --git a/src/app/sequencer.rs b/src/app/sequencer.rs index bd7ce8b..cfd6937 100644 --- a/src/app/sequencer.rs +++ b/src/app/sequencer.rs @@ -54,6 +54,7 @@ impl App { .collect(), quantization: pat.quantization, sync_mode: pat.sync_mode, + follow_up: pat.follow_up, }; let _ = cmd_tx.send(SeqCommand::PatternUpdate { bank, diff --git a/src/app/staging.rs b/src/app/staging.rs index bf791eb..0d635b3 100644 --- a/src/app/staging.rs +++ b/src/app/staging.rs @@ -85,6 +85,7 @@ impl App { pat.speed = props.speed; pat.quantization = props.quantization; pat.sync_mode = props.sync_mode; + pat.follow_up = props.follow_up; self.project_state.mark_dirty(bank, pattern); } diff --git a/src/commands.rs b/src/commands.rs index d2932cf..d1af466 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use crate::model::{LaunchQuantization, PatternSpeed, SyncMode}; +use crate::model::{FollowUp, LaunchQuantization, PatternSpeed, SyncMode}; use crate::page::Page; use crate::state::{ColorScheme, DeviceKind, EngineSection, Modal, OptionsFocus, PatternField, SettingKind}; @@ -144,6 +144,7 @@ pub enum AppCommand { speed: PatternSpeed, quantization: LaunchQuantization, sync_mode: SyncMode, + follow_up: FollowUp, }, // Page navigation diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 3daae01..f464c3f 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -16,7 +16,7 @@ use super::{substeps_in_window, LinkState, StepTiming, SyncTime}; use crate::model::{ CcAccess, Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables, }; -use crate::model::{LaunchQuantization, SyncMode, MAX_BANKS, MAX_PATTERNS}; +use crate::model::{FollowUp, LaunchQuantization, SyncMode, MAX_BANKS, MAX_PATTERNS}; use crate::state::LiveKeyState; #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] @@ -134,6 +134,7 @@ pub struct PatternSnapshot { pub steps: Vec, pub quantization: LaunchQuantization, pub sync_mode: SyncMode, + pub follow_up: FollowUp, } #[derive(Clone)] @@ -523,33 +524,6 @@ struct StepResult { any_step_fired: bool, } -struct VariableReads { - new_tempo: Option, - chain_transitions: Vec<(PatternId, PatternId)>, -} - -fn parse_chain_target(s: &str) -> Option { - let (bank, pattern) = s.split_once(':')?; - Some(PatternId { - bank: bank.parse().ok()?, - pattern: pattern.parse().ok()?, - }) -} - -struct KeyBuf { - speed: String, - chain: String, -} - -impl KeyBuf { - fn new() -> Self { - Self { - speed: String::with_capacity(24), - chain: String::with_capacity(24), - } - } -} - fn format_speed_key(buf: &mut String, bank: usize, pattern: usize) -> &str { use std::fmt::Write; buf.clear(); @@ -557,13 +531,6 @@ fn format_speed_key(buf: &mut String, bank: usize, pattern: usize) -> &str { buf } -fn format_chain_key(buf: &mut String, bank: usize, pattern: usize) -> &str { - use std::fmt::Write; - buf.clear(); - write!(buf, "__chain_{bank}_{pattern}__").unwrap(); - buf -} - pub struct SequencerState { audio_state: AudioState, pattern_cache: PatternCache, @@ -575,7 +542,7 @@ pub struct SequencerState { variables: Variables, dict: Dictionary, speed_overrides: HashMap<(usize, usize), f64>, - key_buf: KeyBuf, + speed_key_buf: String, buf_audio_commands: Vec, buf_activated: Vec, buf_stopped: Vec, @@ -606,7 +573,7 @@ impl SequencerState { variables, dict, speed_overrides: HashMap::with_capacity(MAX_PATTERNS), - key_buf: KeyBuf::new(), + speed_key_buf: String::with_capacity(24), buf_audio_commands: Vec::with_capacity(32), buf_activated: Vec::with_capacity(16), buf_stopped: Vec::with_capacity(16), @@ -757,15 +724,15 @@ impl SequencerState { input.mouse_down, ); - let vars = self.read_variables(&self.buf_completed_iterations, steps.any_step_fired); - self.apply_chain_transitions(vars.chain_transitions); + let new_tempo = self.read_tempo_variable(steps.any_step_fired); + self.apply_follow_ups(); self.audio_state.prev_beat = lookahead_end; let flush = std::mem::take(&mut self.audio_state.flush_midi_notes); TickOutput { audio_commands: std::mem::take(&mut self.buf_audio_commands), - new_tempo: vars.new_tempo, + new_tempo, shared_state: self.build_shared_state(), flush_midi_notes: flush, } @@ -896,7 +863,7 @@ impl SequencerState { { let vars = self.variables.load_full(); for id in self.audio_state.active_patterns.keys() { - let key = format_speed_key(&mut self.key_buf.speed, id.bank, id.pattern); + let key = format_speed_key(&mut self.speed_key_buf, id.bank, id.pattern); if let Some(v) = vars.get(key).and_then(|v: &Value| v.as_float().ok()) { self.speed_overrides.insert((id.bank, id.pattern), v); } @@ -947,8 +914,7 @@ impl SequencerState { active.pattern, source_idx, ); - let speed_key = format_speed_key(&mut self.key_buf.speed, active.bank, active.pattern); - let chain_key = format_chain_key(&mut self.key_buf.chain, active.bank, active.pattern); + let speed_key = format_speed_key(&mut self.speed_key_buf, active.bank, active.pattern); let ctx = StepContext { step: step_idx, beat: step_beat, @@ -964,7 +930,6 @@ impl SequencerState { nudge_secs, cc_access: self.cc_access.as_deref(), speed_key, - chain_key, mouse_x, mouse_y, mouse_down, @@ -1016,14 +981,9 @@ impl SequencerState { result } - fn read_variables(&self, completed: &[PatternId], any_step_fired: bool) -> VariableReads { - let stopped = &self.buf_stopped; - let needs_access = !completed.is_empty() || !stopped.is_empty() || any_step_fired; - if !needs_access { - return VariableReads { - new_tempo: None, - chain_transitions: Vec::new(), - }; + fn read_tempo_variable(&self, any_step_fired: bool) -> Option { + if !any_step_fired { + return None; } let vars = self.variables.load_full(); @@ -1031,76 +991,45 @@ impl SequencerState { .get("__tempo__") .and_then(|v: &Value| v.as_float().ok()); - let mut chain_transitions = Vec::new(); - let mut buf = String::with_capacity(24); - for id in completed { - let chain_key = format_chain_key(&mut buf, id.bank, id.pattern); - if let Some(Value::Str(s, _)) = vars.get(chain_key) { - if let Some(target) = parse_chain_target(s) { - chain_transitions.push((*id, target)); - } - } - } - - // Remove consumed variables (tempo and chain keys) - let mut needs_removal = new_tempo.is_some(); - if !needs_removal { - for id in completed.iter().chain(stopped.iter()) { - if vars.contains_key(format_chain_key(&mut buf, id.bank, id.pattern)) { - needs_removal = true; - break; - } - } - } - - if needs_removal { + if new_tempo.is_some() { let mut new_vars = (*vars).clone(); new_vars.remove("__tempo__"); - for id in completed { - new_vars.remove(format_chain_key(&mut buf, id.bank, id.pattern)); - } - for id in stopped { - new_vars.remove(format_chain_key(&mut buf, id.bank, id.pattern)); - } self.variables.store(Arc::new(new_vars)); } - VariableReads { - new_tempo, - chain_transitions, - } + new_tempo } - fn apply_chain_transitions(&mut self, transitions: Vec<(PatternId, PatternId)>) { - for (source, target) in transitions { - if !self - .audio_state - .pending_stops - .iter() - .any(|p| p.id == source) - { - self.audio_state.pending_stops.push(PendingPattern { - id: source, - quantization: LaunchQuantization::Bar, - sync_mode: SyncMode::Reset, - }); - } - if !self - .audio_state - .pending_starts - .iter() - .any(|p| p.id == target) - { - let (quant, sync) = self - .pattern_cache - .get(target.bank, target.pattern) - .map(|p| (p.quantization, p.sync_mode)) - .unwrap_or((LaunchQuantization::Bar, SyncMode::Reset)); - self.audio_state.pending_starts.push(PendingPattern { - id: target, - quantization: quant, - sync_mode: sync, - }); + fn apply_follow_ups(&mut self) { + for completed_id in &self.buf_completed_iterations { + let Some(pattern) = self.pattern_cache.get(completed_id.bank, completed_id.pattern) else { + continue; + }; + + match pattern.follow_up { + FollowUp::Loop => {} + FollowUp::Stop => { + self.audio_state.pending_stops.push(PendingPattern { + id: *completed_id, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }); + } + FollowUp::Chain { bank, pattern } => { + self.audio_state.pending_stops.push(PendingPattern { + id: *completed_id, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }); + let target = PatternId { bank, pattern }; + if !self.audio_state.pending_starts.iter().any(|p| p.id == target) { + self.audio_state.pending_starts.push(PendingPattern { + id: target, + quantization: LaunchQuantization::Immediate, + sync_mode: SyncMode::Reset, + }); + } + } } } } @@ -1412,6 +1341,7 @@ mod tests { .collect(), quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, + follow_up: FollowUp::default(), } } @@ -1528,68 +1458,6 @@ mod tests { assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 0))); } - #[test] - fn test_chain_requires_active_source() { - let mut state = make_state(); - - // Set up: pattern 0 (length 1) chains to pattern 1 - state.tick(tick_with( - vec![ - SeqCommand::PatternUpdate { - bank: 0, - pattern: 0, - data: simple_pattern(1), - }, - SeqCommand::PatternUpdate { - bank: 0, - pattern: 1, - data: simple_pattern(4), - }, - ], - 0.0, - )); - - // Start pattern 0 - state.tick(tick_with( - vec![SeqCommand::PatternStart { - bank: 0, - pattern: 0, - quantization: LaunchQuantization::Immediate, - sync_mode: SyncMode::Reset, - }], - 0.5, - )); - - // Set chain variable - { - let mut vars = (**state.variables.load()).clone(); - vars.insert( - "__chain_0_0__".to_string(), - Value::Str(std::sync::Arc::from("0:1"), None), - ); - state.variables.store(Arc::new(vars)); - } - - // Pattern 0 completes iteration AND gets stopped immediately in the same tick. - // The stop removes it from active_patterns before chain evaluation, - // so the chain guard (active_patterns.contains_key) blocks the transition. - let output = state.tick(tick_with( - vec![SeqCommand::PatternStop { - bank: 0, - pattern: 0, - quantization: LaunchQuantization::Immediate, - }], - 1.0, - )); - - assert!(output.shared_state.active_patterns.is_empty()); - assert!(!state - .audio_state - .pending_starts - .iter() - .any(|p| p.id == pid(0, 1))); - } - #[test] fn test_pattern_start_cancels_pending_stop() { let mut state = make_state(); @@ -1779,67 +1647,6 @@ mod tests { assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 0))); } - #[test] - fn test_stop_during_iteration_blocks_chain() { - let mut state = make_state(); - - state.tick(tick_with( - vec![ - SeqCommand::PatternUpdate { - bank: 0, - pattern: 0, - data: simple_pattern(1), - }, - SeqCommand::PatternUpdate { - bank: 0, - pattern: 1, - data: simple_pattern(4), - }, - SeqCommand::PatternStart { - bank: 0, - pattern: 0, - quantization: LaunchQuantization::Immediate, - sync_mode: SyncMode::Reset, - }, - ], - 0.0, - )); - - // Pattern 0 is now pending (will activate next tick when prev_beat >= 0) - // Advance so it activates - state.tick(tick_at(0.5, true)); - assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0))); - - // Set chain: 0:0 -> 0:1 - { - let mut vars = (**state.variables.load()).clone(); - vars.insert( - "__chain_0_0__".to_string(), - Value::Str(std::sync::Arc::from("0:1"), None), - ); - state.variables.store(Arc::new(vars)); - } - - // Pattern 0 (length 1) completes iteration at beat=1.0 AND - // an immediate stop removes it from active_patterns first. - // Chain guard should block transition to pattern 1. - state.tick(tick_with( - vec![SeqCommand::PatternStop { - bank: 0, - pattern: 0, - quantization: LaunchQuantization::Immediate, - }], - 1.0, - )); - - assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 1))); - assert!(!state - .audio_state - .pending_starts - .iter() - .any(|p| p.id == pid(0, 1))); - } - #[test] fn test_multiple_patterns_independent_quantization() { let mut state = make_state(); @@ -2100,6 +1907,7 @@ mod tests { .collect(), quantization: LaunchQuantization::Immediate, sync_mode: SyncMode::Reset, + follow_up: FollowUp::default(), } } diff --git a/src/input/modal.rs b/src/input/modal.rs index cfb3996..9d59817 100644 --- a/src/input/modal.rs +++ b/src/input/modal.rs @@ -3,7 +3,7 @@ use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; use super::{InputContext, InputResult}; use crate::commands::AppCommand; use crate::engine::SeqCommand; -use crate::model::PatternSpeed; +use crate::model::{FollowUp, PatternSpeed}; use crate::state::{ ConfirmAction, EditorTarget, EuclideanField, Modal, PatternField, PatternPropsField, RenameTarget, @@ -377,21 +377,45 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input speed, quantization, sync_mode, + follow_up, } => { let (bank, pattern) = (*bank, *pattern); + let is_chain = matches!(follow_up, FollowUp::Chain { .. }); match key.code { - KeyCode::Up => *field = field.prev(), - KeyCode::Down | KeyCode::Tab => *field = field.next(), + KeyCode::Up => *field = field.prev(is_chain), + KeyCode::Down | KeyCode::Tab => *field = field.next(is_chain), KeyCode::Left => match field { PatternPropsField::Speed => *speed = speed.prev(), PatternPropsField::Quantization => *quantization = quantization.prev(), PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(), + PatternPropsField::FollowUp => *follow_up = follow_up.prev_mode(), + PatternPropsField::ChainBank => { + if let FollowUp::Chain { bank: b, .. } = follow_up { + *b = b.saturating_sub(1); + } + } + PatternPropsField::ChainPattern => { + if let FollowUp::Chain { pattern: p, .. } = follow_up { + *p = p.saturating_sub(1); + } + } _ => {} }, KeyCode::Right => match field { PatternPropsField::Speed => *speed = speed.next(), PatternPropsField::Quantization => *quantization = quantization.next(), PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(), + PatternPropsField::FollowUp => *follow_up = follow_up.next_mode(), + PatternPropsField::ChainBank => { + if let FollowUp::Chain { bank: b, .. } = follow_up { + *b = (*b + 1).min(31); + } + } + PatternPropsField::ChainPattern => { + if let FollowUp::Chain { pattern: p, .. } = follow_up { + *p = (*p + 1).min(31); + } + } _ => {} }, KeyCode::Char(c) => match field { @@ -418,6 +442,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input let speed_val = *speed; let quant_val = *quantization; let sync_val = *sync_mode; + let follow_up_val = *follow_up; ctx.dispatch(AppCommand::StagePatternProps { bank, pattern, @@ -426,6 +451,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input speed: speed_val, quantization: quant_val, sync_mode: sync_val, + follow_up: follow_up_val, }); ctx.dispatch(AppCommand::CloseModal); } diff --git a/src/model/mod.rs b/src/model/mod.rs index 4db6f42..b2c309f 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -8,7 +8,7 @@ pub use cagire_forth::{ Variables, Word, WordCompile, WORDS, }; pub use cagire_project::{ - load, save, Bank, LaunchQuantization, Pattern, PatternSpeed, Project, SyncMode, MAX_BANKS, - MAX_PATTERNS, + load, save, Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed, Project, SyncMode, + MAX_BANKS, MAX_PATTERNS, }; pub use script::ScriptEngine; diff --git a/src/services/stack_preview.rs b/src/services/stack_preview.rs index 2f95e28..48cd293 100644 --- a/src/services/stack_preview.rs +++ b/src/services/stack_preview.rs @@ -60,7 +60,6 @@ pub fn update_cache(editor_ctx: &EditorContext) { nudge_secs: 0.0, cc_access: None, speed_key: "", - chain_key: "", mouse_x: 0.5, mouse_y: 0.5, mouse_down: 0.0, diff --git a/src/state/editor.rs b/src/state/editor.rs index 12b1a6b..521f58f 100644 --- a/src/state/editor.rs +++ b/src/state/editor.rs @@ -24,26 +24,37 @@ pub enum PatternPropsField { Speed, Quantization, SyncMode, + FollowUp, + ChainBank, + ChainPattern, } impl PatternPropsField { - pub fn next(&self) -> Self { + pub fn next(&self, follow_up_is_chain: bool) -> Self { match self { Self::Name => Self::Length, Self::Length => Self::Speed, Self::Speed => Self::Quantization, Self::Quantization => Self::SyncMode, - Self::SyncMode => Self::SyncMode, + Self::SyncMode => Self::FollowUp, + Self::FollowUp if follow_up_is_chain => Self::ChainBank, + Self::FollowUp => Self::FollowUp, + Self::ChainBank => Self::ChainPattern, + Self::ChainPattern => Self::ChainPattern, } } - pub fn prev(&self) -> Self { + pub fn prev(&self, follow_up_is_chain: bool) -> Self { match self { Self::Name => Self::Name, Self::Length => Self::Name, Self::Speed => Self::Length, Self::Quantization => Self::Speed, Self::SyncMode => Self::Quantization, + Self::FollowUp => Self::SyncMode, + Self::ChainBank => Self::FollowUp, + Self::ChainPattern if follow_up_is_chain => Self::ChainBank, + Self::ChainPattern => Self::FollowUp, } } } diff --git a/src/state/modal.rs b/src/state/modal.rs index 8f30ebf..d1471a9 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -1,4 +1,4 @@ -use crate::model::{LaunchQuantization, PatternSpeed, SyncMode}; +use crate::model::{FollowUp, LaunchQuantization, PatternSpeed, SyncMode}; use crate::state::editor::{EuclideanField, PatternField, PatternPropsField}; use crate::state::file_browser::FileBrowserState; @@ -77,6 +77,7 @@ pub enum Modal { speed: PatternSpeed, quantization: LaunchQuantization, sync_mode: SyncMode, + follow_up: FollowUp, }, KeybindingsHelp { scroll: usize, diff --git a/src/state/playback.rs b/src/state/playback.rs index 880a608..13f1f05 100644 --- a/src/state/playback.rs +++ b/src/state/playback.rs @@ -1,5 +1,5 @@ use crate::engine::PatternChange; -use crate::model::{LaunchQuantization, PatternSpeed, SyncMode}; +use crate::model::{FollowUp, LaunchQuantization, PatternSpeed, SyncMode}; use std::collections::{HashMap, HashSet}; #[derive(Clone)] @@ -21,6 +21,7 @@ pub struct StagedPropChange { pub speed: PatternSpeed, pub quantization: LaunchQuantization, pub sync_mode: SyncMode, + pub follow_up: FollowUp, } pub struct PlaybackState { diff --git a/src/views/patterns_view.rs b/src/views/patterns_view.rs index f1bb202..155f0d6 100644 --- a/src/views/patterns_view.rs +++ b/src/views/patterns_view.rs @@ -716,6 +716,8 @@ fn render_properties( bank: usize, pattern_idx: usize, ) { + use cagire_project::FollowUp; + let theme = theme::get(); let pattern = &app.project_state.project.banks[bank].patterns[pattern_idx]; @@ -729,7 +731,7 @@ fn render_properties( let label_style = Style::new().fg(theme.ui.text_muted); let value_style = Style::new().fg(theme.ui.text_primary); - let rows: Vec = vec![ + let mut rows: Vec = vec![ Line::from(vec![ Span::styled(" Name ", label_style), Span::styled(name, value_style), @@ -752,5 +754,17 @@ fn render_properties( ]), ]; + if pattern.follow_up != FollowUp::Loop { + let follow_label = match pattern.follow_up { + FollowUp::Loop => unreachable!(), + FollowUp::Stop => "Stop".to_string(), + FollowUp::Chain { bank: b, pattern: p } => format!("Chain B{:02}:P{:02}", b + 1, p + 1), + }; + rows.push(Line::from(vec![ + Span::styled(" After ", label_style), + Span::styled(follow_label, value_style), + ])); + } + frame.render_widget(Paragraph::new(rows), area); } diff --git a/src/views/render.rs b/src/views/render.rs index eb66c0d..8ddc0ea 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -609,37 +609,45 @@ fn render_modal( speed, quantization, sync_mode, + follow_up, } => { + use crate::model::FollowUp; use crate::state::PatternPropsField; + let is_chain = matches!(follow_up, FollowUp::Chain { .. }); + let modal_height = if is_chain { 16 } else { 14 }; + let inner = ModalFrame::new(&format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1)) .width(50) - .height(12) + .height(modal_height) .border_color(theme.modal.input) .render_centered(frame, term); let speed_label = speed.label(); - let fields: Vec<(&str, &str, bool)> = vec![ - ("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, - ), + let follow_up_label = match follow_up { + FollowUp::Loop => "Loop".to_string(), + FollowUp::Stop => "Stop".to_string(), + FollowUp::Chain { bank: b, pattern: p } => { + format!("Chain B{:02}:P{:02}", b + 1, p + 1) + } + }; + let mut fields: Vec<(&str, String, bool)> = vec![ + ("Name", name.clone(), *field == PatternPropsField::Name), + ("Length", length.clone(), *field == PatternPropsField::Length), + ("Speed", speed_label, *field == PatternPropsField::Speed), + ("Quantization", quantization.label().to_string(), *field == PatternPropsField::Quantization), + ("Sync Mode", sync_mode.label().to_string(), *field == PatternPropsField::SyncMode), + ("Follow Up", follow_up_label, *field == PatternPropsField::FollowUp), ]; + if is_chain { + if let FollowUp::Chain { bank: b, pattern: p } = follow_up { + fields.push((" Bank", format!("{:02}", b + 1), *field == PatternPropsField::ChainBank)); + fields.push((" Pattern", format!("{:02}", p + 1), *field == PatternPropsField::ChainPattern)); + } + } - render_props_form(frame, inner, &fields); + let fields_ref: Vec<(&str, &str, bool)> = fields.iter().map(|(l, v, s)| (*l, v.as_str(), *s)).collect(); + render_props_form(frame, inner, &fields_ref); let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); let hints = hint_line(&[ diff --git a/tests/forth/harness.rs b/tests/forth/harness.rs index 43bb1bf..3ef379b 100644 --- a/tests/forth/harness.rs +++ b/tests/forth/harness.rs @@ -22,7 +22,6 @@ pub fn default_ctx() -> StepContext<'static> { nudge_secs: 0.0, cc_access: None, speed_key: "__speed_0_0__", - chain_key: "__chain_0_0__", mouse_x: 0.5, mouse_y: 0.5, mouse_down: 0.0,