From 53fb3eb759d6f716001be22cf1607f0c3bf5f74c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Thu, 5 Feb 2026 00:58:53 +0100 Subject: [PATCH] Feat: prelude and new words --- CHANGELOG.md | 3 + crates/forth/src/ops.rs | 7 ++ crates/forth/src/vm.rs | 90 ++++++++++++++++++++++++ crates/forth/src/words/compile.rs | 7 ++ crates/forth/src/words/core.rs | 60 ++++++++++++++++ crates/forth/src/words/sequencing.rs | 10 +++ crates/project/src/file.rs | 4 ++ crates/project/src/project.rs | 3 + docs/prelude.md | 51 ++++++++++++++ src/app.rs | 72 +++++++++++++++++-- src/commands.rs | 6 ++ src/engine/sequencer.rs | 23 +++--- src/init.rs | 2 + src/input.rs | 35 ++++++++-- src/model/docs.rs | 1 + src/state/editor.rs | 9 +++ src/state/mod.rs | 4 +- src/views/keybindings.rs | 10 +++ src/views/render.rs | 24 ++++--- tests/forth/generator.rs | 40 +++++++++++ tests/forth/stack.rs | 101 +++++++++++++++++++++++++++ 21 files changed, 533 insertions(+), 29 deletions(-) create mode 100644 docs/prelude.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3614bdd..e34cba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file. ### Added - TachyonFX based animations +- Prelude: project-level Forth script for persistent word definitions. Press `d` to edit, `D` to re-evaluate. Runs automatically on playback start and project load. +- Varargs stack words: `rev`, `shuffle`, `sort` (ascending), `rsort` (descending), `sum`, `prod`. All take a count and operate on the top n items. ### Changed @@ -23,6 +25,7 @@ All notable changes to this project will be documented in this file. - Decoupled script runtime state between UI and sequencer threads, eliminating shared mutexes on the RT path. ### Fixed +- Prelude content no longer leaks into step editor. Closing the prelude editor now restores the current step's content to the buffer. - Desktop binary now loads color theme and connects MIDI devices on startup (was missing). - Audio commands no longer silently dropped when channel is full; switched to unbounded channel matching MIDI dispatch pattern. - PatternProps and EuclideanDistribution modals now use the global theme background instead of the terminal default. diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index ef15b0c..59ce81d 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -19,6 +19,12 @@ pub enum Op { Drop2, Swap2, Over2, + Rev, + Shuffle, + Sort, + RSort, + Sum, + Prod, Forget, Add, Sub, @@ -91,6 +97,7 @@ pub enum Op { SetSpeed, At, IntRange, + StepRange, Generate, GeomRange, Times, diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index 2e92ba3..d200052 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -345,6 +345,77 @@ impl Forth { stack.push(a); stack.push(b); } + Op::Rev => { + let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; + if count > stack.len() { + return Err("stack underflow".into()); + } + let start = stack.len() - count; + stack[start..].reverse(); + } + Op::Shuffle => { + let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; + if count > stack.len() { + return Err("stack underflow".into()); + } + let start = stack.len() - count; + let slice = &mut stack[start..]; + let mut rng = self.rng.lock(); + for i in (1..slice.len()).rev() { + let j = rng.gen_range(0..=i); + slice.swap(i, j); + } + } + Op::Sort => { + let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; + if count > stack.len() { + return Err("stack underflow".into()); + } + let start = stack.len() - count; + stack[start..].sort_by(|a, b| { + a.as_float() + .unwrap_or(0.0) + .partial_cmp(&b.as_float().unwrap_or(0.0)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + } + Op::RSort => { + let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; + if count > stack.len() { + return Err("stack underflow".into()); + } + let start = stack.len() - count; + stack[start..].sort_by(|a, b| { + b.as_float() + .unwrap_or(0.0) + .partial_cmp(&a.as_float().unwrap_or(0.0)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + } + Op::Sum => { + let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; + if count > stack.len() { + return Err("stack underflow".into()); + } + let start = stack.len() - count; + let total: f64 = stack + .drain(start..) + .map(|v| v.as_float().unwrap_or(0.0)) + .sum(); + stack.push(float_to_value(total)); + } + Op::Prod => { + let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; + if count > stack.len() { + return Err("stack underflow".into()); + } + let start = stack.len() - count; + let product: f64 = stack + .drain(start..) + .map(|v| v.as_float().unwrap_or(1.0)) + .product(); + stack.push(float_to_value(product)); + } Op::Add => binary_op(stack, |a, b| a + b)?, Op::Sub => binary_op(stack, |a, b| a - b)?, @@ -889,6 +960,25 @@ impl Forth { } } + Op::StepRange => { + let step = stack.pop().ok_or("stack underflow")?.as_float()?; + let end = stack.pop().ok_or("stack underflow")?.as_float()?; + let start = stack.pop().ok_or("stack underflow")?.as_float()?; + if step == 0.0 { + return Err("step cannot be zero".into()); + } + let ascending = step > 0.0; + let mut val = start; + loop { + let done = if ascending { val > end } else { val < end }; + if done { + break; + } + stack.push(float_to_value(val)); + val += step; + } + } + Op::Generate => { let count = stack.pop().ok_or("stack underflow")?.as_int()?; let quot = stack.pop().ok_or("stack underflow")?; diff --git a/crates/forth/src/words/compile.rs b/crates/forth/src/words/compile.rs index a87479c..d05e727 100644 --- a/crates/forth/src/words/compile.rs +++ b/crates/forth/src/words/compile.rs @@ -20,6 +20,12 @@ pub(super) fn simple_op(name: &str) -> Option { "2drop" => Op::Drop2, "2swap" => Op::Swap2, "2over" => Op::Over2, + "rev" => Op::Rev, + "shuffle" => Op::Shuffle, + "sort" => Op::Sort, + "rsort" => Op::RSort, + "sum" => Op::Sum, + "prod" => Op::Prod, "+" => Op::Add, "-" => Op::Sub, "*" => Op::Mul, @@ -83,6 +89,7 @@ pub(super) fn simple_op(name: &str) -> Option { "oct" => Op::Oct, "clear" => Op::ClearCmd, ".." => Op::IntRange, + ".," => Op::StepRange, "gen" => Op::Generate, "geom.." => Op::GeomRange, "times" => Op::Times, diff --git a/crates/forth/src/words/core.rs b/crates/forth/src/words/core.rs index db14e18..81fe969 100644 --- a/crates/forth/src/words/core.rs +++ b/crates/forth/src/words/core.rs @@ -123,6 +123,66 @@ pub(super) const WORDS: &[Word] = &[ compile: Simple, varargs: false, }, + Word { + name: "rev", + aliases: &[], + category: "Stack", + stack: "(..n n -- ..n)", + desc: "Reverse top n items", + example: "1 2 3 3 rev => 3 2 1", + compile: Simple, + varargs: true, + }, + Word { + name: "shuffle", + aliases: &[], + category: "Stack", + stack: "(..n n -- ..n)", + desc: "Randomly shuffle top n items", + example: "1 2 3 3 shuffle", + compile: Simple, + varargs: true, + }, + Word { + name: "sort", + aliases: &[], + category: "Stack", + stack: "(..n n -- ..n)", + desc: "Sort top n items ascending", + example: "3 1 2 3 sort => 1 2 3", + compile: Simple, + varargs: true, + }, + Word { + name: "rsort", + aliases: &[], + category: "Stack", + stack: "(..n n -- ..n)", + desc: "Sort top n items descending", + example: "1 2 3 3 rsort => 3 2 1", + compile: Simple, + varargs: true, + }, + Word { + name: "sum", + aliases: &[], + category: "Stack", + stack: "(..n n -- total)", + desc: "Sum top n items", + example: "1 2 3 3 sum => 6", + compile: Simple, + varargs: true, + }, + Word { + name: "prod", + aliases: &[], + category: "Stack", + stack: "(..n n -- product)", + desc: "Multiply top n items", + example: "2 3 4 3 prod => 24", + compile: Simple, + varargs: true, + }, // Arithmetic Word { name: "+", diff --git a/crates/forth/src/words/sequencing.rs b/crates/forth/src/words/sequencing.rs index ec009d6..aa26763 100644 --- a/crates/forth/src/words/sequencing.rs +++ b/crates/forth/src/words/sequencing.rs @@ -390,6 +390,16 @@ pub(super) const WORDS: &[Word] = &[ compile: Simple, varargs: false, }, + Word { + name: ".,", + aliases: &[], + category: "Generator", + stack: "(start end step -- start start+step ...)", + desc: "Push arithmetic sequence with custom step", + example: "0 1 0.25 ., => 0 0.25 0.5 0.75 1", + compile: Simple, + varargs: false, + }, Word { name: "gen", aliases: &[], diff --git a/crates/project/src/file.rs b/crates/project/src/file.rs index 18d079d..e5ca0fa 100644 --- a/crates/project/src/file.rs +++ b/crates/project/src/file.rs @@ -27,6 +27,8 @@ struct ProjectFile { tempo: f64, #[serde(default)] playing_patterns: Vec<(usize, usize)>, + #[serde(default, skip_serializing_if = "String::is_empty")] + prelude: String, } fn default_tempo() -> f64 { @@ -41,6 +43,7 @@ impl From<&Project> for ProjectFile { sample_paths: project.sample_paths.clone(), tempo: project.tempo, playing_patterns: project.playing_patterns.clone(), + prelude: project.prelude.clone(), } } } @@ -52,6 +55,7 @@ impl From for Project { sample_paths: file.sample_paths, tempo: file.tempo, playing_patterns: file.playing_patterns, + prelude: file.prelude, }; project.normalize(); project diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 53d9cbb..7a3d48f 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -452,6 +452,8 @@ pub struct Project { pub tempo: f64, #[serde(default)] pub playing_patterns: Vec<(usize, usize)>, + #[serde(default)] + pub prelude: String, } fn default_tempo() -> f64 { @@ -465,6 +467,7 @@ impl Default for Project { sample_paths: Vec::new(), tempo: default_tempo(), playing_patterns: Vec::new(), + prelude: String::new(), } } } diff --git a/docs/prelude.md b/docs/prelude.md new file mode 100644 index 0000000..2af5bc5 --- /dev/null +++ b/docs/prelude.md @@ -0,0 +1,51 @@ +# The Prelude + +When you define a word in a step, it becomes available to all steps. But when you close and reopen the project, the dictionary is empty again. Words defined in steps only exist after those steps run. + +The **prelude** solves this. It's a project-wide script that runs automatically when playback starts and when you load a project. + +## Accessing the Prelude + +Press `d` to open the prelude editor. Press `Esc` to save and evaluate. Press `D` (Shift+d) to re-evaluate the prelude without opening the editor. + +## What It's For + +Define words that should be available everywhere, always: + +```forth +: kick "kick" s 0.9 gain . ; +: hat "hat" s 0.4 gain . ; +: bass "saw" s 0.7 gain 200 lpf . ; +``` + +Now every step in your project can use `kick`, `hat`, and `bass` from the first beat. + +## When It Runs + +The prelude evaluates: + +1. When you press Space to start playback (if stopped) +2. When you load a project +3. When you press `D` manually + +It does not run on every step, only once at these moments. This makes it ideal for setup code: word definitions, initial variable values, seed resets. + +## Practical Example + +A prelude for a techno project: + +```forth +: k "kick" s 1.2 attack . ; +: sn "snare" s 0.6 gain 0.02 attack . ; +: hh "hat" s 0.3 gain 8000 hpf . ; +: sub "sine" s 0.8 gain 150 lpf . ; +0 seed +``` + +Step scripts become trivial: + +```forth +c1 note k sub +``` + +The sound design lives in the prelude. Steps focus on rhythm and melody. diff --git a/src/app.rs b/src/app.rs index c19779a..7c5b212 100644 --- a/src/app.rs +++ b/src/app.rs @@ -18,8 +18,8 @@ use crate::page::Page; use crate::services::{clipboard, dict_nav, euclidean, help_nav, pattern_editor}; use crate::settings::Settings; use crate::state::{ - AudioSettings, CyclicEnum, EditorContext, FlashKind, LiveKeyState, Metrics, Modal, - MuteState, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, + AudioSettings, CyclicEnum, EditorContext, EditorTarget, FlashKind, LiveKeyState, Metrics, + Modal, MuteState, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, PlaybackState, ProjectState, StagedChange, StagedPropChange, UiState, }; @@ -164,8 +164,12 @@ impl App { self.project_state.mark_all_dirty(); } - pub fn toggle_playing(&mut self) { + pub fn toggle_playing(&mut self, link: &LinkState) { + let was_playing = self.playback.playing; self.playback.toggle(); + if !was_playing && self.playback.playing { + self.evaluate_prelude(link); + } } pub fn tempo_up(&self, link: &LinkState) { @@ -338,6 +342,58 @@ impl App { self.project_state.mark_dirty(change.bank, change.pattern); } + pub fn open_prelude_editor(&mut self) { + let prelude = &self.project_state.project.prelude; + let lines: Vec = if prelude.is_empty() { + vec![String::new()] + } else { + prelude.lines().map(String::from).collect() + }; + self.editor_ctx.editor.set_content(lines); + let candidates = model::WORDS + .iter() + .map(|w| cagire_ratatui::CompletionCandidate { + name: w.name.to_string(), + signature: w.stack.to_string(), + description: w.desc.to_string(), + example: w.example.to_string(), + }) + .collect(); + self.editor_ctx.editor.set_candidates(candidates); + self.editor_ctx + .editor + .set_completion_enabled(self.ui.show_completion); + self.editor_ctx.target = EditorTarget::Prelude; + self.ui.modal = Modal::Editor; + } + + pub fn save_prelude(&mut self) { + let text = self.editor_ctx.editor.content(); + self.project_state.project.prelude = text; + } + + pub fn close_prelude_editor(&mut self) { + self.editor_ctx.target = EditorTarget::Step; + self.load_step_to_editor(); + } + + pub fn evaluate_prelude(&mut self, link: &LinkState) { + let prelude = &self.project_state.project.prelude; + if prelude.trim().is_empty() { + return; + } + let ctx = self.create_step_context(0, link); + match self.script_engine.evaluate(prelude, &ctx) { + Ok(_) => { + self.ui.flash("Prelude evaluated", 150, FlashKind::Info); + } + Err(e) => { + self.ui + .flash(&format!("Prelude error: {e}"), 300, FlashKind::Error); + } + } + } + pub fn execute_script_oneshot( &self, script: &str, @@ -614,6 +670,8 @@ impl App { self.variables.store(Arc::new(HashMap::new())); self.dict.lock().clear(); + self.evaluate_prelude(link); + for (bank, pattern) in playing { self.playback.queued_changes.push(StagedChange { change: PatternChange::Start { bank, pattern }, @@ -862,7 +920,7 @@ impl App { pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) { match cmd { // Playback - AppCommand::TogglePlaying => self.toggle_playing(), + AppCommand::TogglePlaying => self.toggle_playing(link), AppCommand::TempoUp => self.tempo_up(link), AppCommand::TempoDown => self.tempo_down(link), @@ -1278,6 +1336,12 @@ impl App { FlashKind::Success, ); } + + // Prelude + AppCommand::OpenPreludeEditor => self.open_prelude_editor(), + AppCommand::SavePrelude => self.save_prelude(), + AppCommand::EvaluatePrelude => self.evaluate_prelude(link), + AppCommand::ClosePreludeEditor => self.close_prelude_editor(), } } diff --git a/src/commands.rs b/src/commands.rs index c970a7d..c4dd221 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -221,4 +221,10 @@ pub enum AppCommand { steps: usize, rotation: usize, }, + + // Prelude + OpenPreludeEditor, + SavePrelude, + EvaluatePrelude, + ClosePreludeEditor, } diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index b05f385..50a91f0 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -269,6 +269,8 @@ pub struct SequencerConfig { pub audio_sample_pos: Arc, pub sample_rate: Arc, pub cc_access: Option>, + pub variables: Variables, + pub dict: Dictionary, #[cfg(feature = "desktop")] pub mouse_x: Arc, #[cfg(feature = "desktop")] @@ -301,6 +303,8 @@ pub fn spawn_sequencer( let shared_state = Arc::new(ArcSwap::from_pointee(SharedSequencerState::default())); let shared_state_clone = Arc::clone(&shared_state); + let variables = config.variables; + let dict = config.dict; #[cfg(feature = "desktop")] let mouse_x = config.mouse_x; #[cfg(feature = "desktop")] @@ -335,6 +339,8 @@ pub fn spawn_sequencer( config.audio_sample_pos, config.sample_rate, config.cc_access, + variables, + dict, #[cfg(feature = "desktop")] mouse_x, #[cfg(feature = "desktop")] @@ -529,6 +535,7 @@ pub(crate) struct SequencerState { event_count: usize, script_engine: ScriptEngine, variables: Variables, + dict: Dictionary, speed_overrides: HashMap<(usize, usize), f64>, key_cache: KeyCache, buf_audio_commands: Vec, @@ -547,7 +554,7 @@ impl SequencerState { rng: Rng, cc_access: Option>, ) -> Self { - let script_engine = ScriptEngine::new(Arc::clone(&variables), dict, rng); + let script_engine = ScriptEngine::new(Arc::clone(&variables), Arc::clone(&dict), rng); Self { audio_state: AudioState::new(), pattern_cache: PatternCache::new(), @@ -557,6 +564,7 @@ impl SequencerState { event_count: 0, script_engine, variables, + dict, speed_overrides: HashMap::with_capacity(MAX_PATTERNS), key_cache: KeyCache::new(), buf_audio_commands: Vec::with_capacity(32), @@ -663,12 +671,9 @@ impl SequencerState { self.audio_state.flush_midi_notes = true; } SeqCommand::ResetScriptState => { - let variables: Variables = Arc::new(ArcSwap::from_pointee(HashMap::new())); - let dict: Dictionary = Arc::new(Mutex::new(HashMap::new())); - let rng: Rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0))); - self.script_engine = - ScriptEngine::new(Arc::clone(&variables), dict, rng); - self.variables = variables; + // Clear shared state instead of replacing - preserves sharing with app + self.variables.store(Arc::new(HashMap::new())); + self.dict.lock().clear(); self.speed_overrides.clear(); } SeqCommand::Shutdown => {} @@ -1070,6 +1075,8 @@ fn sequencer_loop( audio_sample_pos: Arc, sample_rate: Arc, cc_access: Option>, + variables: Variables, + dict: Dictionary, #[cfg(feature = "desktop")] mouse_x: Arc, #[cfg(feature = "desktop")] mouse_y: Arc, #[cfg(feature = "desktop")] mouse_down: Arc, @@ -1078,8 +1085,6 @@ fn sequencer_loop( set_realtime_priority(); - let variables: Variables = Arc::new(ArcSwap::from_pointee(HashMap::new())); - let dict: Dictionary = Arc::new(Mutex::new(HashMap::new())); let rng: Rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0))); let mut seq_state = SequencerState::new(variables, dict, rng, cc_access); diff --git a/src/init.rs b/src/init.rs index 6618a72..96653e6 100644 --- a/src/init.rs +++ b/src/init.rs @@ -133,6 +133,8 @@ pub fn init(args: InitArgs) -> Init { audio_sample_pos: Arc::clone(&audio_sample_pos), sample_rate: Arc::clone(&sample_rate_shared), cc_access: Some(Arc::new(app.midi.cc_memory.clone()) as Arc), + variables: Arc::clone(&app.variables), + dict: Arc::clone(&app.dict), #[cfg(feature = "desktop")] mouse_x: Arc::clone(&mouse_x), #[cfg(feature = "desktop")] diff --git a/src/input.rs b/src/input.rs index ac806d6..4455111 100644 --- a/src/input.rs +++ b/src/input.rs @@ -11,8 +11,8 @@ use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot}; use crate::model::PatternSpeed; use crate::page::Page; use crate::state::{ - CyclicEnum, DeviceKind, EngineSection, EuclideanField, Modal, OptionsFocus, PanelFocus, - PatternField, PatternPropsField, SampleBrowserState, SettingKind, SidePanel, + CyclicEnum, DeviceKind, EditorTarget, EngineSection, EuclideanField, Modal, OptionsFocus, + PanelFocus, PatternField, PatternPropsField, SampleBrowserState, SettingKind, SidePanel, }; pub enum InputResult { @@ -488,14 +488,31 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } else if editor.completion_active() { editor.dismiss_completion(); } else { - ctx.dispatch(AppCommand::SaveEditorToStep); - ctx.dispatch(AppCommand::CompileCurrentStep); + match ctx.app.editor_ctx.target { + EditorTarget::Step => { + ctx.dispatch(AppCommand::SaveEditorToStep); + ctx.dispatch(AppCommand::CompileCurrentStep); + } + EditorTarget::Prelude => { + ctx.dispatch(AppCommand::SavePrelude); + ctx.dispatch(AppCommand::EvaluatePrelude); + ctx.dispatch(AppCommand::ClosePreludeEditor); + } + } ctx.dispatch(AppCommand::CloseModal); } } KeyCode::Char('e') if ctrl => { - ctx.dispatch(AppCommand::SaveEditorToStep); - ctx.dispatch(AppCommand::CompileCurrentStep); + match ctx.app.editor_ctx.target { + EditorTarget::Step => { + ctx.dispatch(AppCommand::SaveEditorToStep); + ctx.dispatch(AppCommand::CompileCurrentStep); + } + EditorTarget::Prelude => { + ctx.dispatch(AppCommand::SavePrelude); + ctx.dispatch(AppCommand::EvaluatePrelude); + } + } } KeyCode::Char('f') if ctrl => { editor.activate_search(); @@ -1082,6 +1099,12 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR ctx.dispatch(AppCommand::ClearSolos); ctx.app.send_mute_state(ctx.seq_cmd_tx); } + KeyCode::Char('d') => { + ctx.dispatch(AppCommand::OpenPreludeEditor); + } + KeyCode::Char('D') => { + ctx.dispatch(AppCommand::EvaluatePrelude); + } _ => {} } InputResult::Continue diff --git a/src/model/docs.rs b/src/model/docs.rs index 82943a1..6a8ee56 100644 --- a/src/model/docs.rs +++ b/src/model/docs.rs @@ -27,6 +27,7 @@ pub const DOCS: &[DocEntry] = &[ Topic("The Dictionary", include_str!("../../docs/dictionary.md")), Topic("The Stack", include_str!("../../docs/stack.md")), Topic("Creating Words", include_str!("../../docs/definitions.md")), + Topic("The Prelude", include_str!("../../docs/prelude.md")), Topic("Oddities", include_str!("../../docs/oddities.md")), // Audio Engine Section("Audio Engine"), diff --git a/src/state/editor.rs b/src/state/editor.rs index 8073e12..dda07b8 100644 --- a/src/state/editor.rs +++ b/src/state/editor.rs @@ -3,6 +3,13 @@ use std::ops::RangeInclusive; use cagire_ratatui::Editor; +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum EditorTarget { + #[default] + Step, + Prelude, +} + #[derive(Clone, Copy, PartialEq, Eq)] pub enum PatternField { Length, @@ -76,6 +83,7 @@ pub struct EditorContext { pub copied_steps: Option, pub show_stack: bool, pub stack_cache: RefCell>, + pub target: EditorTarget, } #[derive(Clone)] @@ -125,6 +133,7 @@ impl Default for EditorContext { copied_steps: None, show_stack: false, stack_cache: RefCell::new(None), + target: EditorTarget::default(), } } } diff --git a/src/state/mod.rs b/src/state/mod.rs index 884e1d5..aa5c89b 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -32,8 +32,8 @@ pub mod ui; pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, SettingKind}; pub use color_scheme::ColorScheme; pub use editor::{ - CopiedStepData, CopiedSteps, EditorContext, EuclideanField, PatternField, PatternPropsField, - StackCache, + CopiedStepData, CopiedSteps, EditorContext, EditorTarget, EuclideanField, PatternField, + PatternPropsField, StackCache, }; pub use live_keys::LiveKeyState; pub use modal::Modal; diff --git a/src/views/keybindings.rs b/src/views/keybindings.rs index ba3dc25..b719b18 100644 --- a/src/views/keybindings.rs +++ b/src/views/keybindings.rs @@ -37,6 +37,12 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str bindings.push(("r", "Rename", "Rename current step")); bindings.push(("Ctrl+R", "Run", "Run step script immediately")); bindings.push(("e", "Euclidean", "Distribute linked steps using Euclidean rhythm")); + bindings.push(("m", "Mute", "Stage mute for current pattern")); + bindings.push(("x", "Solo", "Stage solo for current pattern")); + bindings.push(("M", "Clear mutes", "Clear all mutes")); + bindings.push(("X", "Clear solos", "Clear all solos")); + bindings.push(("d", "Prelude", "Edit prelude script")); + bindings.push(("D", "Eval prelude", "Re-evaluate prelude without editing")); } Page::Patterns => { bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns")); @@ -46,6 +52,10 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str bindings.push(("c", "Commit", "Commit staged changes")); bindings.push(("r", "Rename", "Rename bank/pattern")); bindings.push(("e", "Properties", "Edit pattern properties")); + bindings.push(("m", "Mute", "Stage mute for pattern")); + bindings.push(("x", "Solo", "Stage solo for pattern")); + bindings.push(("M", "Clear mutes", "Clear all mutes")); + bindings.push(("X", "Clear solos", "Clear all solos")); bindings.push(("Ctrl+C", "Copy", "Copy bank/pattern")); bindings.push(("Ctrl+V", "Paste", "Paste bank/pattern")); bindings.push(("Del", "Reset", "Reset bank/pattern")); diff --git a/src/views/render.rs b/src/views/render.rs index fae2c9d..57a44bd 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -11,7 +11,7 @@ use crate::engine::{LinkState, SequencerSnapshot}; use crate::model::SourceSpan; use crate::page::Page; use crate::state::{ - EuclideanField, FlashKind, Modal, PanelFocus, PatternField, SidePanel, + EditorTarget, EuclideanField, FlashKind, Modal, PanelFocus, PatternField, SidePanel, }; use crate::theme; use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime}; @@ -629,8 +629,6 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, 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 step = app.current_edit_pattern().step(app.editor_ctx.step); let flash_kind = app.ui.flash_kind(); let border_color = match flash_kind { @@ -640,10 +638,17 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term None => theme.modal.editor, }; - let title = if let Some(ref name) = step.and_then(|s| s.name.as_ref()) { - format!("Step {step_num:02}: {name}") - } else { - format!("Step {step_num:02} Script") + let title = match app.editor_ctx.target { + EditorTarget::Prelude => "Prelude".to_string(), + EditorTarget::Step => { + let step_num = app.editor_ctx.step + 1; + let step = app.current_edit_pattern().step(app.editor_ctx.step); + if let Some(ref name) = step.and_then(|s| s.name.as_ref()) { + format!("Step {step_num:02}: {name}") + } else { + format!("Step {step_num:02} Script") + } + } }; let inner = ModalFrame::new(&title) @@ -652,7 +657,10 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term .border_color(border_color) .render_centered(frame, term); - let trace = if app.ui.runtime_highlight && app.playback.playing { + let trace = if app.ui.runtime_highlight + && app.playback.playing + && app.editor_ctx.target == EditorTarget::Step + { let source = app .current_edit_pattern() .resolve_source(app.editor_ctx.step); diff --git a/tests/forth/generator.rs b/tests/forth/generator.rs index 8dc3b97..cdf7c5e 100644 --- a/tests/forth/generator.rs +++ b/tests/forth/generator.rs @@ -102,3 +102,43 @@ fn geom_zero_count() { fn geom_underflow() { expect_error("1 2 geom..", "stack underflow"); } + +#[test] +fn step_range_ascending() { + expect_stack("0 1 0.25 .,", &[ + int(0), + Value::Float(0.25, None), + Value::Float(0.5, None), + Value::Float(0.75, None), + int(1), + ]); +} + +#[test] +fn step_range_descending() { + expect_stack("1 0 -0.5 .,", &[ + int(1), + Value::Float(0.5, None), + int(0), + ]); +} + +#[test] +fn step_range_integer_step() { + expect_stack("0 6 2 .,", &[int(0), int(2), int(4), int(6)]); +} + +#[test] +fn step_range_single() { + expect_stack("5 5 1 .,", &[int(5)]); +} + +#[test] +fn step_range_zero_step_error() { + expect_error("0 10 0 .,", "step cannot be zero"); +} + +#[test] +fn step_range_underflow() { + expect_error("0 1 .,", "stack underflow"); +} diff --git a/tests/forth/stack.rs b/tests/forth/stack.rs index 5537afb..6d88844 100644 --- a/tests/forth/stack.rs +++ b/tests/forth/stack.rs @@ -152,3 +152,104 @@ fn clear_stack() { f.clear_stack(); assert!(f.stack().is_empty()); } + +#[test] +fn rev() { + expect_stack("1 2 3 3 rev", &[int(3), int(2), int(1)]); +} + +#[test] +fn rev_partial() { + expect_stack("1 2 3 4 2 rev", &[int(1), int(2), int(4), int(3)]); +} + +#[test] +fn rev_one() { + expect_stack("1 2 3 1 rev", &[int(1), int(2), int(3)]); +} + +#[test] +fn rev_zero() { + expect_stack("1 2 3 0 rev", &[int(1), int(2), int(3)]); +} + +#[test] +fn rev_underflow() { + expect_error("1 2 5 rev", "stack underflow"); +} + +#[test] +fn shuffle_preserves_elements() { + let f = forth(); + f.evaluate("1 2 3 4 4 shuffle", &default_ctx()).unwrap(); + let mut stack: Vec = f + .stack() + .iter() + .map(|v| match v { + Value::Int(n, _) => *n, + _ => panic!("expected int"), + }) + .collect(); + stack.sort(); + assert_eq!(stack, vec![1, 2, 3, 4]); +} + +#[test] +fn shuffle_underflow() { + expect_error("1 2 5 shuffle", "stack underflow"); +} + +#[test] +fn sort() { + expect_stack("3 1 4 1 5 5 sort", &[int(1), int(1), int(3), int(4), int(5)]); +} + +#[test] +fn sort_partial() { + expect_stack("9 3 1 2 3 sort", &[int(9), int(1), int(2), int(3)]); +} + +#[test] +fn sort_underflow() { + expect_error("1 2 5 sort", "stack underflow"); +} + +#[test] +fn rsort() { + expect_stack("1 3 2 3 rsort", &[int(3), int(2), int(1)]); +} + +#[test] +fn sum() { + expect_stack("1 2 3 4 4 sum", &[int(10)]); +} + +#[test] +fn sum_single() { + expect_stack("42 1 sum", &[int(42)]); +} + +#[test] +fn sum_zero() { + expect_stack("1 2 3 0 sum", &[int(1), int(2), int(3), int(0)]); +} + +#[test] +fn sum_underflow() { + expect_error("1 2 5 sum", "stack underflow"); +} + +#[test] +fn prod() { + expect_stack("2 3 4 3 prod", &[int(24)]); +} + +#[test] +fn prod_single() { + expect_stack("7 1 prod", &[int(7)]); +} + +#[test] +fn prod_zero_count() { + expect_stack("1 2 3 0 prod", &[int(1), int(2), int(3), int(1)]); +}