Feat: prelude and new words
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s

This commit is contained in:
2026-02-05 00:58:53 +01:00
parent abafea8ddf
commit de56598fca
21 changed files with 533 additions and 29 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -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")?;

View File

@@ -20,6 +20,12 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"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<Op> {
"oct" => Op::Oct,
"clear" => Op::ClearCmd,
".." => Op::IntRange,
".," => Op::StepRange,
"gen" => Op::Generate,
"geom.." => Op::GeomRange,
"times" => Op::Times,

View File

@@ -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: "+",

View File

@@ -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: &[],

View File

@@ -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<ProjectFile> for Project {
sample_paths: file.sample_paths,
tempo: file.tempo,
playing_patterns: file.playing_patterns,
prelude: file.prelude,
};
project.normalize();
project

View File

@@ -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(),
}
}
}

51
docs/prelude.md Normal file
View File

@@ -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.

View File

@@ -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<String> = 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(),
}
}

View File

@@ -221,4 +221,10 @@ pub enum AppCommand {
steps: usize,
rotation: usize,
},
// Prelude
OpenPreludeEditor,
SavePrelude,
EvaluatePrelude,
ClosePreludeEditor,
}

View File

@@ -269,6 +269,8 @@ pub struct SequencerConfig {
pub audio_sample_pos: Arc<AtomicU64>,
pub sample_rate: Arc<std::sync::atomic::AtomicU32>,
pub cc_access: Option<Arc<dyn CcAccess>>,
pub variables: Variables,
pub dict: Dictionary,
#[cfg(feature = "desktop")]
pub mouse_x: Arc<AtomicU32>,
#[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<TimestampedCommand>,
@@ -547,7 +554,7 @@ impl SequencerState {
rng: Rng,
cc_access: Option<Arc<dyn CcAccess>>,
) -> 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<AtomicU64>,
sample_rate: Arc<std::sync::atomic::AtomicU32>,
cc_access: Option<Arc<dyn CcAccess>>,
variables: Variables,
dict: Dictionary,
#[cfg(feature = "desktop")] mouse_x: Arc<AtomicU32>,
#[cfg(feature = "desktop")] mouse_y: Arc<AtomicU32>,
#[cfg(feature = "desktop")] mouse_down: Arc<AtomicU32>,
@@ -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);

View File

@@ -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<dyn model::CcAccess>),
variables: Arc::clone(&app.variables),
dict: Arc::clone(&app.dict),
#[cfg(feature = "desktop")]
mouse_x: Arc::clone(&mouse_x),
#[cfg(feature = "desktop")]

View File

@@ -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,15 +488,32 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
} else if editor.completion_active() {
editor.dismiss_completion();
} else {
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 => {
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

View File

@@ -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"),

View File

@@ -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<CopiedSteps>,
pub show_stack: bool,
pub stack_cache: RefCell<Option<StackCache>>,
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(),
}
}
}

View File

@@ -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;

View File

@@ -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"));

View File

@@ -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()) {
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);

View File

@@ -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");
}

View File

@@ -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<i64> = 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)]);
}