WIP simplify

This commit is contained in:
2026-01-29 09:38:41 +01:00
parent f1f1b28b31
commit 4d0d837e14
11 changed files with 434 additions and 291 deletions

View File

@@ -1,6 +1,6 @@
use super::ops::Op; use super::ops::Op;
use super::types::{Dictionary, SourceSpan}; use super::types::{Dictionary, SourceSpan};
use super::words::{compile_word, simple_op}; use super::words::compile_word;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum Token { enum Token {
@@ -25,6 +25,11 @@ fn tokenize(input: &str) -> Vec<Token> {
continue; continue;
} }
if c == '(' || c == ')' {
chars.next();
continue;
}
if c == '"' { if c == '"' {
let start = pos; let start = pos;
chars.next(); chars.next();
@@ -88,7 +93,6 @@ fn tokenize(input: &str) -> Vec<Token> {
fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> { fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> {
let mut ops = Vec::new(); let mut ops = Vec::new();
let mut i = 0; let mut i = 0;
let mut list_depth: usize = 0;
while i < tokens.len() { while i < tokens.len() {
match &tokens[i] { match &tokens[i] {
@@ -122,20 +126,6 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> {
ops.push(Op::Branch(else_ops.len())); ops.push(Op::Branch(else_ops.len()));
ops.extend(else_ops); ops.extend(else_ops);
} }
} else if is_list_start(word) {
ops.push(Op::ListStart);
list_depth += 1;
} else if is_list_end(word) {
list_depth = list_depth.saturating_sub(1);
if let Some(op) = simple_op(word) {
ops.push(op);
}
} else if list_depth > 0 {
let mut word_ops = Vec::new();
if !compile_word(word, Some(*span), &mut word_ops, dict) {
return Err(format!("unknown word: {word}"));
}
ops.push(Op::Quotation(word_ops, Some(*span)));
} else if !compile_word(word, Some(*span), &mut ops, dict) { } else if !compile_word(word, Some(*span), &mut ops, dict) {
return Err(format!("unknown word: {word}")); return Err(format!("unknown word: {word}"));
} }
@@ -147,14 +137,6 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> {
Ok(ops) Ok(ops)
} }
fn is_list_start(word: &str) -> bool {
matches!(word, "[" | "<" | "<<")
}
fn is_list_end(word: &str) -> bool {
matches!(word, "]" | ">" | ">>")
}
fn compile_quotation(tokens: &[Token], dict: &Dictionary) -> Result<(Vec<Op>, usize, SourceSpan), String> { fn compile_quotation(tokens: &[Token], dict: &Dictionary) -> Result<(Vec<Op>, usize, SourceSpan), String> {
let mut depth = 1; let mut depth = 1;
let mut end_idx = None; let mut end_idx = None;

View File

@@ -55,17 +55,14 @@ pub enum Op {
Rand, Rand,
Seed, Seed,
Cycle, Cycle,
PCycle,
TCycle,
Choose, Choose,
ChanceExec, ChanceExec,
ProbExec, ProbExec,
Coin, Coin,
Mtof, Mtof,
Ftom, Ftom,
ListStart,
ListEnd,
ListEndCycle,
PCycle,
ListEndPCycle,
SetTempo, SetTempo,
Every, Every,
Quotation(Vec<Op>, Option<SourceSpan>), Quotation(Vec<Op>, Option<SourceSpan>),
@@ -85,4 +82,5 @@ pub enum Op {
EmitN, EmitN,
ClearCmd, ClearCmd,
SetSpeed, SetSpeed,
At,
} }

View File

@@ -47,8 +47,8 @@ pub enum Value {
Int(i64, Option<SourceSpan>), Int(i64, Option<SourceSpan>),
Float(f64, Option<SourceSpan>), Float(f64, Option<SourceSpan>),
Str(String, Option<SourceSpan>), Str(String, Option<SourceSpan>),
Marker,
Quotation(Vec<Op>, Option<SourceSpan>), Quotation(Vec<Op>, Option<SourceSpan>),
CycleList(Vec<Value>),
} }
impl PartialEq for Value { impl PartialEq for Value {
@@ -57,8 +57,8 @@ impl PartialEq for Value {
(Value::Int(a, _), Value::Int(b, _)) => a == b, (Value::Int(a, _), Value::Int(b, _)) => a == b,
(Value::Float(a, _), Value::Float(b, _)) => a == b, (Value::Float(a, _), Value::Float(b, _)) => a == b,
(Value::Str(a, _), Value::Str(b, _)) => a == b, (Value::Str(a, _), Value::Str(b, _)) => a == b,
(Value::Marker, Value::Marker) => true,
(Value::Quotation(a, _), Value::Quotation(b, _)) => a == b, (Value::Quotation(a, _), Value::Quotation(b, _)) => a == b,
(Value::CycleList(a), Value::CycleList(b)) => a == b,
_ => false, _ => false,
} }
} }
@@ -93,29 +93,25 @@ impl Value {
Value::Int(i, _) => *i != 0, Value::Int(i, _) => *i != 0,
Value::Float(f, _) => *f != 0.0, Value::Float(f, _) => *f != 0.0,
Value::Str(s, _) => !s.is_empty(), Value::Str(s, _) => !s.is_empty(),
Value::Marker => false,
Value::Quotation(..) => true, Value::Quotation(..) => true,
Value::CycleList(items) => !items.is_empty(),
} }
} }
pub(super) fn is_marker(&self) -> bool {
matches!(self, Value::Marker)
}
pub(super) fn to_param_string(&self) -> String { pub(super) fn to_param_string(&self) -> String {
match self { match self {
Value::Int(i, _) => i.to_string(), Value::Int(i, _) => i.to_string(),
Value::Float(f, _) => f.to_string(), Value::Float(f, _) => f.to_string(),
Value::Str(s, _) => s.clone(), Value::Str(s, _) => s.clone(),
Value::Marker => String::new(),
Value::Quotation(..) => String::new(), Value::Quotation(..) => String::new(),
Value::CycleList(_) => String::new(),
} }
} }
pub(super) fn span(&self) -> Option<SourceSpan> { pub(super) fn span(&self) -> Option<SourceSpan> {
match self { match self {
Value::Int(_, s) | Value::Float(_, s) | Value::Str(_, s) | Value::Quotation(_, s) => *s, Value::Int(_, s) | Value::Float(_, s) | Value::Str(_, s) | Value::Quotation(_, s) => *s,
Value::Marker => None, Value::CycleList(_) => None,
} }
} }
} }
@@ -124,6 +120,7 @@ impl Value {
pub(super) struct CmdRegister { pub(super) struct CmdRegister {
sound: Option<Value>, sound: Option<Value>,
params: Vec<(String, Value)>, params: Vec<(String, Value)>,
deltas: Vec<Value>,
} }
impl CmdRegister { impl CmdRegister {
@@ -135,6 +132,14 @@ impl CmdRegister {
self.params.push((key, val)); self.params.push((key, val));
} }
pub(super) fn set_deltas(&mut self, deltas: Vec<Value>) {
self.deltas = deltas;
}
pub(super) fn deltas(&self) -> &[Value] {
&self.deltas
}
pub(super) fn snapshot(&self) -> Option<(Value, Vec<(String, Value)>)> { pub(super) fn snapshot(&self) -> Option<(Value, Vec<(String, Value)>)> {
self.sound self.sound
.as_ref() .as_ref()

View File

@@ -145,35 +145,26 @@ impl Forth {
select_and_run(selected, stack, outputs, cmd) select_and_run(selected, stack, outputs, cmd)
}; };
let drain_list_select_run = |idx_source: usize, let emit_with_cycling = |cmd: &CmdRegister, emit_idx: usize, delta_secs: f64, outputs: &mut Vec<String>| -> Result<Option<Value>, String> {
err_msg: &str,
stack: &mut Vec<Value>,
outputs: &mut Vec<String>,
cmd: &mut CmdRegister|
-> Result<(), String> {
let mut values = Vec::new();
while let Some(v) = stack.pop() {
if v.is_marker() {
break;
}
values.push(v);
}
if values.is_empty() {
return Err(err_msg.into());
}
values.reverse();
let idx = idx_source % values.len();
let selected = values[idx].clone();
select_and_run(selected, stack, outputs, cmd)
};
let emit_once = |cmd: &CmdRegister, outputs: &mut Vec<String>| -> Result<Option<Value>, String> {
let (sound_val, params) = cmd.snapshot().ok_or("no sound set")?; let (sound_val, params) = cmd.snapshot().ok_or("no sound set")?;
let sound = sound_val.as_str()?.to_string(); let resolved_sound_val = resolve_cycling(&sound_val, emit_idx);
// Note: sound span is recorded by Op::Emit, not here
let sound = resolved_sound_val.as_str()?.to_string();
let resolved_params: Vec<(String, String)> = let resolved_params: Vec<(String, String)> =
params.iter().map(|(k, v)| (k.clone(), v.to_param_string())).collect(); params.iter().map(|(k, v)| {
emit_output(&sound, &resolved_params, ctx.step_duration(), ctx.nudge_secs, outputs); let resolved = resolve_cycling(v, emit_idx);
Ok(Some(sound_val)) // Record selected span for params if they came from a CycleList
if let Value::CycleList(_) = v {
if let Some(span) = resolved.span() {
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
trace.selected_spans.push(span);
}
}
}
(k.clone(), resolved.to_param_string())
}).collect();
emit_output(&sound, &resolved_params, ctx.step_duration(), delta_secs, outputs);
Ok(Some(resolved_sound_val))
}; };
while pc < ops.len() { while pc < ops.len() {
@@ -361,12 +352,28 @@ impl Forth {
} }
Op::Emit => { Op::Emit => {
if let Some(sound_val) = emit_once(cmd, outputs)? { let deltas = if cmd.deltas().is_empty() {
if let Some(span) = sound_val.span() { vec![Value::Float(0.0, None)]
} else {
cmd.deltas().to_vec()
};
for (emit_idx, delta_val) in deltas.iter().enumerate() {
let delta_frac = delta_val.as_float()?;
let delta_secs = ctx.nudge_secs + delta_frac * ctx.step_duration();
// Record delta span for highlighting
if let Some(span) = delta_val.span() {
if let Some(trace) = trace_cell.borrow_mut().as_mut() { if let Some(trace) = trace_cell.borrow_mut().as_mut() {
trace.selected_spans.push(span); trace.selected_spans.push(span);
} }
} }
if let Some(sound_val) = emit_with_cycling(cmd, emit_idx, delta_secs, outputs)? {
if let Some(span) = sound_val.span() {
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
trace.selected_spans.push(span);
}
}
}
} }
} }
@@ -438,6 +445,19 @@ impl Forth {
drain_select_run(count, idx, stack, outputs, cmd)?; drain_select_run(count, idx, stack, outputs, cmd)?;
} }
Op::TCycle => {
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
if count == 0 {
return Err("tcycle count must be > 0".into());
}
if stack.len() < count {
return Err("stack underflow".into());
}
let start = stack.len() - count;
let values: Vec<Value> = stack.drain(start..).collect();
stack.push(Value::CycleList(values));
}
Op::Choose => { Op::Choose => {
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
if count == 0 { if count == 0 {
@@ -606,37 +626,23 @@ impl Forth {
cmd.set_param("dur".into(), Value::Float(dur, None)); cmd.set_param("dur".into(), Value::Float(dur, None));
} }
Op::ListStart => { Op::At => {
stack.push(Value::Marker); let top = stack.pop().ok_or("stack underflow")?;
} let deltas = match &top {
Value::Float(..) => vec![top],
Op::ListEnd => { Value::Int(n, _) if *n > 0 && stack.len() >= *n as usize => {
let mut count = 0; let count = *n as usize;
let mut values = Vec::new(); let mut vals = Vec::with_capacity(count);
while let Some(v) = stack.pop() { for _ in 0..count {
if v.is_marker() { vals.push(stack.pop().ok_or("stack underflow")?);
break; }
vals.reverse();
vals
} }
values.push(v); Value::Int(..) => vec![top],
count += 1; _ => return Err("at expects number or list".into()),
}
values.reverse();
for v in values {
stack.push(v);
}
stack.push(Value::Int(count, None));
}
Op::ListEndCycle | Op::ListEndPCycle => {
let idx_source = match &ops[pc] {
Op::ListEndCycle => ctx.runs,
_ => ctx.iter,
}; };
let err_msg = match &ops[pc] { cmd.set_deltas(deltas);
Op::ListEndCycle => "empty cycle list",
_ => "empty pattern cycle list",
};
drain_list_select_run(idx_source, err_msg, stack, outputs, cmd)?;
} }
Op::Adsr => { Op::Adsr => {
@@ -699,8 +705,8 @@ impl Forth {
if n < 0 { if n < 0 {
return Err("emit count must be >= 0".into()); return Err("emit count must be >= 0".into());
} }
for _ in 0..n { for i in 0..n as usize {
emit_once(cmd, outputs)?; emit_with_cycling(cmd, i, ctx.nudge_secs, outputs)?;
} }
} }
} }
@@ -846,3 +852,12 @@ fn format_cmd(pairs: &[(String, String)]) -> String {
let parts: Vec<String> = pairs.iter().map(|(k, v)| format!("{k}/{v}")).collect(); let parts: Vec<String> = pairs.iter().map(|(k, v)| format!("{k}/{v}")).collect();
format!("/{}", parts.join("/")) format!("/{}", parts.join("/"))
} }
fn resolve_cycling(val: &Value, emit_idx: usize) -> Value {
match val {
Value::CycleList(items) if !items.is_empty() => {
items[emit_idx % items.len()].clone()
}
other => other.clone(),
}
}

View File

@@ -34,7 +34,7 @@ pub const WORDS: &[Word] = &[
}, },
Word { Word {
name: "dupn", name: "dupn",
aliases: &[], aliases: &["!"],
category: "Stack", category: "Stack",
stack: "(a n -- a a ... a)", stack: "(a n -- a a ... a)",
desc: "Duplicate a onto stack n times", desc: "Duplicate a onto stack n times",
@@ -482,19 +482,28 @@ pub const WORDS: &[Word] = &[
Word { Word {
name: "cycle", name: "cycle",
aliases: &[], aliases: &[],
category: "Lists", category: "Selection",
stack: "(..n n -- val)", stack: "(v1..vn n -- selected)",
desc: "Cycle through n items by step", desc: "Cycle through n items by step runs",
example: "1 2 3 3 cycle", example: "60 64 67 3 cycle",
compile: Simple, compile: Simple,
}, },
Word { Word {
name: "pcycle", name: "pcycle",
aliases: &[], aliases: &[],
category: "Lists", category: "Selection",
stack: "(..n n -- val)", stack: "(v1..vn n -- selected)",
desc: "Cycle through n items by pattern", desc: "Cycle through n items by pattern iteration",
example: "1 2 3 3 pcycle", example: "60 64 67 3 pcycle",
compile: Simple,
},
Word {
name: "tcycle",
aliases: &[],
category: "Selection",
stack: "(v1..vn n -- CycleList)",
desc: "Create cycle list for emit-time resolution",
example: "60 64 67 3 tcycle note",
compile: Simple, compile: Simple,
}, },
Word { Word {
@@ -799,41 +808,13 @@ pub const WORDS: &[Word] = &[
example: "1 4 chain", example: "1 4 chain",
compile: Simple, compile: Simple,
}, },
// Lists
Word { Word {
name: "[", name: "at",
aliases: &["<", "<<"],
category: "Lists",
stack: "(-- marker)",
desc: "Start list",
example: "[ 1 2 3 ]",
compile: Simple,
},
Word {
name: "]",
aliases: &[], aliases: &[],
category: "Lists", category: "Time",
stack: "(marker..n -- n)", stack: "(list|n --)",
desc: "End list, push count", desc: "Set delta context for emit timing",
example: "[ 1 2 3 ] => 3", example: "[ 0 0.5 ] at kick s . => emits at 0 and 0.5 of step",
compile: Simple,
},
Word {
name: ">",
aliases: &[],
category: "Lists",
stack: "(marker..n -- val)",
desc: "End cycle list, pick by step",
example: "< 1 2 3 > => cycles through 1, 2, 3",
compile: Simple,
},
Word {
name: ">>",
aliases: &[],
category: "Lists",
stack: "(marker..n -- val)",
desc: "End pattern cycle list, pick by pattern",
example: "<< 1 2 3 >> => cycles through 1, 2, 3 per pattern",
compile: Simple, compile: Simple,
}, },
// Quotations // Quotations
@@ -2050,6 +2031,7 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"seed" => Op::Seed, "seed" => Op::Seed,
"cycle" => Op::Cycle, "cycle" => Op::Cycle,
"pcycle" => Op::PCycle, "pcycle" => Op::PCycle,
"tcycle" => Op::TCycle,
"choose" => Op::Choose, "choose" => Op::Choose,
"every" => Op::Every, "every" => Op::Every,
"chance" => Op::ChanceExec, "chance" => Op::ChanceExec,
@@ -2061,10 +2043,7 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"!?" => Op::Unless, "!?" => Op::Unless,
"tempo!" => Op::SetTempo, "tempo!" => Op::SetTempo,
"speed!" => Op::SetSpeed, "speed!" => Op::SetSpeed,
"[" => Op::ListStart, "at" => Op::At,
"]" => Op::ListEnd,
">" => Op::ListEndCycle,
">>" => Op::ListEndPCycle,
"adsr" => Op::Adsr, "adsr" => Op::Adsr,
"ad" => Op::Ad, "ad" => Op::Ad,
"apply" => Op::Apply, "apply" => Op::Apply,

View File

@@ -476,6 +476,9 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
KeyCode::Char('p') if ctrl => { KeyCode::Char('p') if ctrl => {
editor.search_prev(); editor.search_prev();
} }
KeyCode::Char('k') if ctrl => {
ctx.app.editor_ctx.show_stack = !ctx.app.editor_ctx.show_stack;
}
KeyCode::Char('a') if ctrl => { KeyCode::Char('a') if ctrl => {
editor.select_all(); editor.select_all();
} }

View File

@@ -54,6 +54,7 @@ pub struct EditorContext {
pub editor: Editor, pub editor: Editor,
pub selection_anchor: Option<usize>, pub selection_anchor: Option<usize>,
pub copied_steps: Option<CopiedSteps>, pub copied_steps: Option<CopiedSteps>,
pub show_stack: bool,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -94,6 +95,7 @@ impl Default for EditorContext {
editor: Editor::new(), editor: Editor::new(),
selection_anchor: None, selection_anchor: None,
copied_steps: None, copied_steps: None,
show_stack: false,
} }
} }
} }

View File

@@ -1,14 +1,19 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::Instant;
use rand::rngs::StdRng;
use rand::SeedableRng;
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table}; use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table};
use ratatui::Frame; use ratatui::Frame;
use cagire_forth::Forth;
use crate::app::App; use crate::app::App;
use crate::engine::{LinkState, SequencerSnapshot}; use crate::engine::{LinkState, SequencerSnapshot};
use crate::model::SourceSpan; use crate::model::{SourceSpan, StepContext, Value};
use crate::page::Page; use crate::page::Page;
use crate::state::{FlashKind, Modal, PanelFocus, PatternField, SidePanel}; use crate::state::{FlashKind, Modal, PanelFocus, PatternField, SidePanel};
use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime}; use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime};
@@ -20,6 +25,64 @@ use super::{
dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view, dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view,
}; };
fn compute_stack_display(lines: &[String], editor: &cagire_ratatui::Editor) -> String {
let cursor_line = editor.cursor().0;
let partial: Vec<&str> = lines.iter().take(cursor_line + 1).map(|s| s.as_str()).collect();
let script = partial.join("\n");
if script.trim().is_empty() {
return "Stack: []".to_string();
}
let vars = Arc::new(Mutex::new(HashMap::new()));
let dict = Arc::new(Mutex::new(HashMap::new()));
let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(42)));
let forth = Forth::new(vars, dict, rng);
let ctx = StepContext {
step: 0,
beat: 0.0,
bank: 0,
pattern: 0,
tempo: 120.0,
phase: 0.0,
slot: 0,
runs: 0,
iter: 0,
speed: 1.0,
fill: false,
nudge_secs: 0.0,
};
match forth.evaluate(&script, &ctx) {
Ok(_) => {
let stack = forth.stack();
let formatted: Vec<String> = stack.iter().map(format_value).collect();
format!("Stack: [{}]", formatted.join(" "))
}
Err(e) => format!("Error: {e}"),
}
}
fn format_value(v: &Value) -> String {
match v {
Value::Int(n, _) => n.to_string(),
Value::Float(f, _) => {
if f.fract() == 0.0 && f.abs() < 1_000_000.0 {
format!("{f:.1}")
} else {
format!("{f:.4}")
}
}
Value::Str(s, _) => format!("\"{s}\""),
Value::Quotation(..) => "[...]".to_string(),
Value::CycleList(items) => {
let inner: Vec<String> = items.iter().map(format_value).collect();
format!("({})", inner.join(" "))
}
}
}
fn adjust_spans_for_line( fn adjust_spans_for_line(
spans: &[SourceSpan], spans: &[SourceSpan],
line_start: usize, line_start: usize,
@@ -619,28 +682,24 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
let show_search = app.editor_ctx.editor.search_active() let show_search = app.editor_ctx.editor.search_active()
|| !app.editor_ctx.editor.search_query().is_empty(); || !app.editor_ctx.editor.search_query().is_empty();
let (search_area, editor_area, hint_area) = if show_search { let reserved_lines = 1 + if show_search { 1 } else { 0 };
let search_area = Rect::new(inner.x, inner.y, inner.width, 1); let editor_height = inner.height.saturating_sub(reserved_lines);
let editor_area = Rect::new(
inner.x, let mut y = inner.y;
inner.y + 1,
inner.width, let search_area = if show_search {
inner.height.saturating_sub(2), let area = Rect::new(inner.x, y, inner.width, 1);
); y += 1;
let hint_area = Some(area)
Rect::new(inner.x, inner.y + 1 + editor_area.height, inner.width, 1);
(Some(search_area), editor_area, hint_area)
} else { } else {
let editor_area = Rect::new( None
inner.x,
inner.y,
inner.width,
inner.height.saturating_sub(1),
);
let hint_area = Rect::new(inner.x, inner.y + editor_area.height, inner.width, 1);
(None, editor_area, hint_area)
}; };
let editor_area = Rect::new(inner.x, y, inner.width, editor_height);
y += editor_height;
let hint_area = Rect::new(inner.x, y, inner.width, 1);
if let Some(sa) = search_area { if let Some(sa) = search_area {
let style = if app.editor_ctx.editor.search_active() { let style = if app.editor_ctx.editor.search_active() {
Style::default().fg(Color::Yellow) Style::default().fg(Color::Yellow)
@@ -671,32 +730,52 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
let dim = Style::default().fg(Color::DarkGray); let dim = Style::default().fg(Color::DarkGray);
let key = Style::default().fg(Color::Yellow); let key = Style::default().fg(Color::Yellow);
let hint = if app.editor_ctx.editor.search_active() {
Line::from(vec![ if app.editor_ctx.editor.search_active() {
let hint = Line::from(vec![
Span::styled("Enter", key), Span::styled("Enter", key),
Span::styled(" confirm ", dim), Span::styled(" confirm ", dim),
Span::styled("Esc", key), Span::styled("Esc", key),
Span::styled(" cancel", dim), Span::styled(" cancel", dim),
]);
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area);
} else if app.editor_ctx.show_stack {
let stack_text = compute_stack_display(text_lines, &app.editor_ctx.editor);
let hint = Line::from(vec![
Span::styled("Esc", key),
Span::styled(" save ", dim),
Span::styled("C-e", key),
Span::styled(" eval ", dim),
Span::styled("C-k", key),
Span::styled(" hide", dim),
]);
let [hint_left, stack_right] = Layout::horizontal([
Constraint::Length(hint.width() as u16),
Constraint::Fill(1),
]) ])
.areas(hint_area);
frame.render_widget(Paragraph::new(hint), hint_left);
frame.render_widget(
Paragraph::new(Span::styled(stack_text, dim)).alignment(Alignment::Right),
stack_right,
);
} else { } else {
Line::from(vec![ let hint = Line::from(vec![
Span::styled("Esc", key), Span::styled("Esc", key),
Span::styled(" save ", dim), Span::styled(" save ", dim),
Span::styled("C-e", key), Span::styled("C-e", key),
Span::styled(" eval ", dim), Span::styled(" eval ", dim),
Span::styled("C-f", key), Span::styled("C-f", key),
Span::styled(" find ", dim), Span::styled(" find ", dim),
Span::styled("C-n", key), Span::styled("C-k", key),
Span::styled("/", dim), Span::styled(" stack ", dim),
Span::styled("C-p", key),
Span::styled(" next/prev ", dim),
Span::styled("C-u", key), Span::styled("C-u", key),
Span::styled("/", dim), Span::styled("/", dim),
Span::styled("C-r", key), Span::styled("C-r", key),
Span::styled(" undo/redo", dim), Span::styled(" undo/redo", dim),
]) ]);
}; frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area);
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area); }
} }
Modal::PatternProps { Modal::PatternProps {
bank, bank,

View File

@@ -51,22 +51,6 @@ fn string_with_spaces() {
} }
} }
#[test]
fn list_count() {
let f = run("[ 1 2 3 ]");
assert_eq!(stack_int(&f), 3);
}
#[test]
fn list_empty() {
expect_int("[ ]", 0);
}
#[test]
fn list_preserves_values() {
expect_int("[ 10 20 ] drop +", 30);
}
#[test] #[test]
fn conditional_based_on_step() { fn conditional_based_on_step() {
let ctx0 = ctx_with(|c| c.step = 0); let ctx0 = ctx_with(|c| c.step = 0);

View File

@@ -1,124 +1,106 @@
use super::harness::*; use super::harness::*;
#[test] #[test]
fn choose_word_from_list() { fn choose_from_stack() {
// 1 2 [ + - ] choose: picks + or -, applies to 1 2
// With seed 42, choose picks one deterministically
let f = forth(); let f = forth();
let ctx = default_ctx(); let ctx = default_ctx();
f.evaluate("1 2 [ + - ] choose", &ctx).unwrap(); f.evaluate("1 2 3 3 choose", &ctx).unwrap();
let val = stack_int(&f); let val = stack_int(&f);
assert!(val == 3 || val == -1, "expected 3 or -1, got {}", val); assert!(val >= 1 && val <= 3, "expected 1, 2, or 3, got {}", val);
} }
#[test] #[test]
fn cycle_word_from_list() { fn cycle_by_runs() {
// At runs=0, picks first word (dup)
let ctx = ctx_with(|c| c.runs = 0); let ctx = ctx_with(|c| c.runs = 0);
let f = run_ctx("5 < dup nip >", &ctx); let f = run_ctx("10 20 30 3 cycle", &ctx);
assert_eq!(stack_int(&f), 5); // dup leaves 5 5, but stack check takes top
// At runs=1, picks second word (2 *)
let f = forth();
let ctx = ctx_with(|c| c.runs = 1);
f.evaluate(": double 2 * ; 5 < dup double >", &ctx).unwrap();
assert_eq!(stack_int(&f), 10); assert_eq!(stack_int(&f), 10);
}
#[test]
fn user_word_in_list() {
let f = forth();
let ctx = ctx_with(|c| c.runs = 0);
f.evaluate(": add3 3 + ; : add5 5 + ; 10 < add3 add5 >", &ctx).unwrap();
assert_eq!(stack_int(&f), 13); // runs=0 picks add3
}
#[test]
fn user_word_in_list_second() {
let f = forth();
let ctx = ctx_with(|c| c.runs = 1); let ctx = ctx_with(|c| c.runs = 1);
f.evaluate(": add3 3 + ; : add5 5 + ; 10 < add3 add5 >", &ctx).unwrap(); let f = run_ctx("10 20 30 3 cycle", &ctx);
assert_eq!(stack_int(&f), 15); // runs=1 picks add5 assert_eq!(stack_int(&f), 20);
}
#[test]
fn values_in_list_still_work() {
// Numbers inside lists should still push as values (not quotations)
let ctx = ctx_with(|c| c.runs = 0);
let f = run_ctx("< 10 20 30 >", &ctx);
assert_eq!(stack_int(&f), 10);
}
#[test]
fn values_in_list_cycle() {
let ctx = ctx_with(|c| c.runs = 2); let ctx = ctx_with(|c| c.runs = 2);
let f = run_ctx("< 10 20 30 >", &ctx); let f = run_ctx("10 20 30 3 cycle", &ctx);
assert_eq!(stack_int(&f), 30);
let ctx = ctx_with(|c| c.runs = 3);
let f = run_ctx("10 20 30 3 cycle", &ctx);
assert_eq!(stack_int(&f), 10);
}
#[test]
fn pcycle_by_iter() {
let ctx = ctx_with(|c| c.iter = 0);
let f = run_ctx("10 20 30 3 pcycle", &ctx);
assert_eq!(stack_int(&f), 10);
let ctx = ctx_with(|c| c.iter = 1);
let f = run_ctx("10 20 30 3 pcycle", &ctx);
assert_eq!(stack_int(&f), 20);
let ctx = ctx_with(|c| c.iter = 2);
let f = run_ctx("10 20 30 3 pcycle", &ctx);
assert_eq!(stack_int(&f), 30); assert_eq!(stack_int(&f), 30);
} }
#[test] #[test]
fn mixed_values_and_words() { fn cycle_with_quotations() {
// Values stay as values, words become quotations
// [ 10 20 ] choose just picks a number
let f = forth();
let ctx = default_ctx();
f.evaluate("[ 10 20 ] choose", &ctx).unwrap();
let val = stack_int(&f);
assert!(val == 10 || val == 20, "expected 10 or 20, got {}", val);
}
#[test]
fn word_with_sound_params() {
let f = forth();
let ctx = ctx_with(|c| c.runs = 0); let ctx = ctx_with(|c| c.runs = 0);
let outputs = f.evaluate( let f = run_ctx("5 { dup } { 2 * } 2 cycle", &ctx);
": myverb 0.5 verb ; \"sine\" s 440 freq < myverb > .",
&ctx
).unwrap();
assert_eq!(outputs.len(), 1);
assert!(outputs[0].contains("verb/0.5"), "expected verb/0.5 in {}", outputs[0]);
}
#[test]
fn arithmetic_word_in_list() {
// 3 4 [ + ] choose -> picks + (only option), applies to 3 4 = 7
let ctx = ctx_with(|c| c.runs = 0);
let f = run_ctx("3 4 < + >", &ctx);
assert_eq!(stack_int(&f), 7);
}
#[test]
fn pcycle_word_from_list() {
let ctx = ctx_with(|c| c.iter = 0);
let f = run_ctx("10 << dup 2 * >>", &ctx);
// iter=0 picks dup: 10 10
let stack = f.stack(); let stack = f.stack();
assert_eq!(stack.len(), 2); assert_eq!(stack.len(), 2);
assert_eq!(stack_int(&f), 5);
let ctx = ctx_with(|c| c.runs = 1);
let f = run_ctx("5 { dup } { 2 * } 2 cycle", &ctx);
assert_eq!(stack_int(&f), 10); assert_eq!(stack_int(&f), 10);
} }
#[test] #[test]
fn pcycle_word_second() { fn cycle_executes_quotation() {
let ctx = ctx_with(|c| c.iter = 1);
let f = run_ctx("10 << dup 2 * >>", &ctx);
// iter=1 picks "2 *" — but wait, each token is its own element
// so << dup 2 * >> has 3 elements: {dup}, 2, {*}
// iter=1 picks element index 1 which is value 2
assert_eq!(stack_int(&f), 2);
}
#[test]
fn multi_op_quotation_in_list() {
// Use { } for multi-op quotations inside lists
let ctx = ctx_with(|c| c.runs = 0); let ctx = ctx_with(|c| c.runs = 0);
let f = run_ctx("10 < { 2 * } { 3 + } >", &ctx); let f = run_ctx("10 { 3 + } { 5 + } 2 cycle", &ctx);
assert_eq!(stack_int(&f), 20); // runs=0 picks {2 *} assert_eq!(stack_int(&f), 13);
} }
#[test] #[test]
fn multi_op_quotation_second() { fn dupn_basic() {
let ctx = ctx_with(|c| c.runs = 1); // 5 3 dupn -> 5 5 5, then + + -> 15
let f = run_ctx("10 < { 2 * } { 3 + } >", &ctx); expect_int("5 3 dupn + +", 15);
assert_eq!(stack_int(&f), 13); // runs=1 picks {3 +}
} }
#[test]
fn dupn_alias() {
expect_int("5 3 ! + +", 15);
}
#[test]
fn tcycle_creates_cycle_list() {
let outputs = expect_outputs(r#"0.0 at 60 64 67 3 tcycle note sine s ."#, 1);
assert!(outputs[0].contains("note/60"));
}
#[test]
fn tcycle_with_multiple_emits() {
let f = forth();
let ctx = default_ctx();
let outputs = f.evaluate(r#"0 0.5 2 at 60 64 2 tcycle note sine s ."#, &ctx).unwrap();
assert_eq!(outputs.len(), 2);
assert!(outputs[0].contains("note/60"));
assert!(outputs[1].contains("note/64"));
}
#[test]
fn cycle_zero_count_error() {
expect_error("1 2 3 0 cycle", "cycle count must be > 0");
}
#[test]
fn choose_zero_count_error() {
expect_error("1 2 3 0 choose", "choose count must be > 0");
}
#[test]
fn tcycle_zero_count_error() {
expect_error("1 2 3 0 tcycle", "tcycle count must be > 0");
}

View File

@@ -42,6 +42,13 @@ fn get_sounds(outputs: &[String]) -> Vec<String> {
.collect() .collect()
} }
fn get_param(outputs: &[String], param: &str) -> Vec<f64> {
outputs
.iter()
.map(|o| parse_params(o).get(param).copied().unwrap_or(0.0))
.collect()
}
const EPSILON: f64 = 1e-9; const EPSILON: f64 = 1e-9;
fn approx_eq(a: f64, b: f64) -> bool { fn approx_eq(a: f64, b: f64) -> bool {
@@ -95,29 +102,29 @@ fn dur_is_step_duration() {
} }
#[test] #[test]
fn cycle_picks_by_step() { fn cycle_picks_by_runs() {
for runs in 0..4 { for runs in 0..4 {
let ctx = ctx_with(|c| c.runs = runs); let ctx = ctx_with(|c| c.runs = runs);
let f = forth(); let f = forth();
let outputs = f.evaluate(r#""kick" s < . _ >"#, &ctx).unwrap(); let outputs = f.evaluate(r#""kick" s { . } { } 2 cycle"#, &ctx).unwrap();
if runs % 2 == 0 { if runs % 2 == 0 {
assert_eq!(outputs.len(), 1, "runs={}: . should be picked", runs); assert_eq!(outputs.len(), 1, "runs={}: emit should be picked", runs);
} else { } else {
assert_eq!(outputs.len(), 0, "runs={}: _ should be picked", runs); assert_eq!(outputs.len(), 0, "runs={}: no-op should be picked", runs);
} }
} }
} }
#[test] #[test]
fn pcycle_picks_by_pattern() { fn pcycle_picks_by_iter() {
for iter in 0..4 { for iter in 0..4 {
let ctx = ctx_with(|c| c.iter = iter); let ctx = ctx_with(|c| c.iter = iter);
let f = forth(); let f = forth();
let outputs = f.evaluate(r#""kick" s << . _ >>"#, &ctx).unwrap(); let outputs = f.evaluate(r#""kick" s { . } { } 2 pcycle"#, &ctx).unwrap();
if iter % 2 == 0 { if iter % 2 == 0 {
assert_eq!(outputs.len(), 1, "iter={}: . should be picked", iter); assert_eq!(outputs.len(), 1, "iter={}: emit should be picked", iter);
} else { } else {
assert_eq!(outputs.len(), 0, "iter={}: _ should be picked", iter); assert_eq!(outputs.len(), 0, "iter={}: no-op should be picked", iter);
} }
} }
} }
@@ -127,7 +134,10 @@ fn cycle_with_sounds() {
for runs in 0..3 { for runs in 0..3 {
let ctx = ctx_with(|c| c.runs = runs); let ctx = ctx_with(|c| c.runs = runs);
let f = forth(); let f = forth();
let outputs = f.evaluate(r#"< { "kick" s . } { "hat" s . } { "snare" s . } >"#, &ctx).unwrap(); let outputs = f.evaluate(
r#"{ "kick" s . } { "hat" s . } { "snare" s . } 3 cycle"#,
&ctx
).unwrap();
assert_eq!(outputs.len(), 1, "runs={}: expected 1 output", runs); assert_eq!(outputs.len(), 1, "runs={}: expected 1 output", runs);
let sounds = get_sounds(&outputs); let sounds = get_sounds(&outputs);
let expected = ["kick", "hat", "snare"][runs % 3]; let expected = ["kick", "hat", "snare"][runs % 3];
@@ -154,3 +164,107 @@ fn emit_n_negative_error() {
let result = f.evaluate(r#""kick" s -1 .!"#, &default_ctx()); let result = f.evaluate(r#""kick" s -1 .!"#, &default_ctx());
assert!(result.is_err()); assert!(result.is_err());
} }
#[test]
fn at_single_delta() {
let outputs = expect_outputs(r#"0.5 at "kick" s ."#, 1);
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
assert!(approx_eq(deltas[0], 0.5 * step_dur), "expected delta at 0.5 of step, got {}", deltas[0]);
}
#[test]
fn at_list_deltas() {
let outputs = expect_outputs(r#"0 0.5 2 at "kick" s ."#, 2);
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
assert!(approx_eq(deltas[0], 0.0), "expected delta 0, got {}", deltas[0]);
assert!(approx_eq(deltas[1], 0.5 * step_dur), "expected delta at 0.5 of step, got {}", deltas[1]);
}
#[test]
fn at_three_deltas() {
let outputs = expect_outputs(r#"0 0.33 0.67 3 at "kick" s ."#, 3);
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
assert!(approx_eq(deltas[0], 0.0), "expected delta 0");
assert!((deltas[1] - 0.33 * step_dur).abs() < 0.001, "expected delta at 0.33 of step");
assert!((deltas[2] - 0.67 * step_dur).abs() < 0.001, "expected delta at 0.67 of step");
}
#[test]
fn at_persists_across_emits() {
let outputs = expect_outputs(r#"0 0.5 2 at "kick" s . "hat" s ."#, 4);
let sounds = get_sounds(&outputs);
assert_eq!(sounds, vec!["kick", "kick", "hat", "hat"]);
}
#[test]
fn tcycle_basic() {
let outputs = expect_outputs(r#"0 0.5 0.75 3 at 60 64 67 3 tcycle note sine s ."#, 3);
let notes = get_param(&outputs, "note");
assert_eq!(notes, vec![60.0, 64.0, 67.0]);
}
#[test]
fn tcycle_wraps() {
let outputs = expect_outputs(r#"0 0.33 0.67 3 at 60 64 2 tcycle note sine s ."#, 3);
let notes = get_param(&outputs, "note");
assert_eq!(notes, vec![60.0, 64.0, 60.0]);
}
#[test]
fn tcycle_with_sound() {
let outputs = expect_outputs(r#"0 0.5 2 at kick hat 2 tcycle s ."#, 2);
let sounds = get_sounds(&outputs);
assert_eq!(sounds, vec!["kick", "hat"]);
}
#[test]
fn tcycle_multiple_params() {
let outputs = expect_outputs(r#"0 0.5 0.75 3 at 60 64 67 3 tcycle note 0.5 1.0 2 tcycle gain sine s ."#, 3);
let notes = get_param(&outputs, "note");
let gains = get_param(&outputs, "gain");
assert_eq!(notes, vec![60.0, 64.0, 67.0]);
assert_eq!(gains, vec![0.5, 1.0, 0.5]);
}
#[test]
fn at_reset_with_zero() {
let outputs = expect_outputs(r#"0 0.5 2 at "kick" s . 0.0 at "hat" s ."#, 3);
let sounds = get_sounds(&outputs);
assert_eq!(sounds, vec!["kick", "kick", "hat"]);
}
#[test]
fn tcycle_records_selected_spans() {
use cagire::forth::ExecutionTrace;
let f = forth();
let mut trace = ExecutionTrace::default();
let script = r#"0 0.5 2 at kick hat 2 tcycle s ."#;
f.evaluate_with_trace(script, &default_ctx(), &mut trace).unwrap();
// Should have 4 selected spans:
// - 2 for at deltas (0 and 0.5)
// - 2 for tcycle sound values (kick and hat)
assert_eq!(trace.selected_spans.len(), 4, "expected 4 selected spans (2 at + 2 tcycle)");
}
#[test]
fn at_records_selected_spans() {
use cagire::forth::ExecutionTrace;
let f = forth();
let mut trace = ExecutionTrace::default();
let script = r#"0 0.5 0.75 3 at "kick" s ."#;
f.evaluate_with_trace(script, &default_ctx(), &mut trace).unwrap();
// Should have 6 selected spans: 3 for at deltas + 3 for sound (one per emit)
assert_eq!(trace.selected_spans.len(), 6, "expected 6 selected spans (3 at + 3 sound)");
// Verify at delta spans (even indices: 0, 2, 4)
assert_eq!(&script[trace.selected_spans[0].start..trace.selected_spans[0].end], "0");
assert_eq!(&script[trace.selected_spans[2].start..trace.selected_spans[2].end], "0.5");
assert_eq!(&script[trace.selected_spans[4].start..trace.selected_spans[4].end], "0.75");
}