9 Commits

Author SHA1 Message Date
b2871ac251 Feat: F1 F2 F3
Some checks failed
Deploy Website / deploy (push) Failing after 4m53s
2026-02-14 15:13:21 +01:00
8ba89f91a0 Fixes 2026-02-10 23:51:17 +01:00
7d670dacb9 Re-update cargo 2026-02-10 21:42:24 +01:00
1de8c068f6 Feat: all engine params use varargs and can eat the stack, document it as such 2026-02-10 19:41:59 +01:00
d792f011ee Feat: rescale spectrum 2026-02-10 19:32:51 +01:00
897f1a776e Feat: reverb words 2026-02-10 19:27:11 +01:00
869d3af244 Feat: entretien de la codebase 2026-02-09 21:12:49 +01:00
a5f17687f1 chore: Release 2026-02-08 13:57:52 +01:00
5b851751e5 Feat: update the CHANGELOG.md correctly 2026-02-08 13:57:25 +01:00
41 changed files with 3431 additions and 3339 deletions

View File

@@ -2,24 +2,22 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [Unreleased] ## [0.0.9]
### Added
- Resolved value annotations: nondeterministic words (`rand`, `choose`, `cycle`, `bounce`, `wchoose`, `coin`, `chance`, `prob`, `exprand`, `logrand`) now display their resolved value inline (e.g., `choose [sine]`, `rand [7]`, `chance [yes]`) during playback in both Preview and Editor modals.
## [0.0.9] - 2026-02-08
### Website ### Website
- Compressed screenshot images: resized to 1600px and converted PNG to WebP (8MB → 538KB). - Compressed screenshot images: resized to 1600px and converted PNG to WebP (8MB → 538KB).
- Version number displayed in subtitle, read automatically from `Cargo.toml` at build time. - Version number displayed in subtitle, read automatically from `Cargo.toml` at build time.
### Added ### Added
- `arp` word for arpeggios: wraps stack values into an arpeggio list that spreads notes across time positions instead of playing them all simultaneously. With explicit `at` deltas, arp items zip with deltas (cycling the shorter list); without `at`, the step is auto-subdivided evenly. Example: `sine s c4 e4 g4 b4 arp note .` plays a 4-note arpeggio across the step.
- Resolved value annotations: nondeterministic words (`rand`, `choose`, `cycle`, `bounce`, `wchoose`, `coin`, `chance`, `prob`, `exprand`, `logrand`) now display their resolved value inline (e.g., `choose [sine]`, `rand [7]`, `chance [yes]`) during playback in both Preview and Editor modals.
- Inline sample finder in the editor: press `Ctrl+B` to open a fuzzy-search popup of all sample folder names. Type to filter, `Ctrl+N`/`Ctrl+P` to navigate, `Tab`/`Enter` to insert the folder name at cursor, `Esc` to dismiss. Mutually exclusive with word completion. - Inline sample finder in the editor: press `Ctrl+B` to open a fuzzy-search popup of all sample folder names. Type to filter, `Ctrl+N`/`Ctrl+P` to navigate, `Tab`/`Enter` to insert the folder name at cursor, `Esc` to dismiss. Mutually exclusive with word completion.
- Sample browser now displays the 0-based file index next to each sample name, making it easy to reference samples by index in Forth scripts (e.g., `"drums" bank 0 n`). - Sample browser now displays the 0-based file index next to each sample name, making it easy to reference samples by index in Forth scripts (e.g., `"drums" bank 0 n`).
### Improved ### Improved
- Header bar stats block (CPU/voices/Link peers) is now centered like all other header sections. - Header bar stats block (CPU/voices/Link peers) is now centered like all other header sections.
- CPU percentage changes color when load is high: accent color at 50%+, error color at 80%+. - CPU percentage changes color when load is high: accent color at 50%+, error color at 80%+.
- Extracted 6 reusable TUI components into `cagire-ratatui`: `CategoryList`, `render_scroll_indicators`, `render_search_bar`, `render_section_header`, `render_props_form`, `hint_line`. Reduces duplication across views.
### Fixed ### Fixed
- Soundless emits (e.g., `1 gain .`) no longer stack infinite voices. All emitted commands now receive a default duration of one beat unless the user explicitly sets `dur`. Use `0 dur` for intentionally infinite voices. - Soundless emits (e.g., `1 gain .`) no longer stack infinite voices. All emitted commands now receive a default duration of one beat unless the user explicitly sets `dur`. Use `0 dur` for intentionally infinite voices.

View File

@@ -2,7 +2,7 @@
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"] members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"]
[workspace.package] [workspace.package]
version = "0.0.8" version = "0.0.9"
edition = "2021" edition = "2021"
authors = ["Raphaël Forment <raphael.forment@gmail.com>"] authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
license = "AGPL-3.0" license = "AGPL-3.0"

View File

@@ -98,6 +98,7 @@ pub enum Op {
ClearCmd, ClearCmd,
SetSpeed, SetSpeed,
At, At,
Arp,
IntRange, IntRange,
StepRange, StepRange,
Generate, Generate,

View File

@@ -88,6 +88,7 @@ pub enum Value {
Str(Arc<str>, Option<SourceSpan>), Str(Arc<str>, Option<SourceSpan>),
Quotation(Arc<[Op]>, Option<SourceSpan>), Quotation(Arc<[Op]>, Option<SourceSpan>),
CycleList(Arc<[Value]>), CycleList(Arc<[Value]>),
ArpList(Arc<[Value]>),
} }
impl PartialEq for Value { impl PartialEq for Value {
@@ -98,6 +99,7 @@ impl PartialEq for Value {
(Value::Str(a, _), Value::Str(b, _)) => a == b, (Value::Str(a, _), Value::Str(b, _)) => a == b,
(Value::Quotation(a, _), Value::Quotation(b, _)) => a == b, (Value::Quotation(a, _), Value::Quotation(b, _)) => a == b,
(Value::CycleList(a), Value::CycleList(b)) => a == b, (Value::CycleList(a), Value::CycleList(b)) => a == b,
(Value::ArpList(a), Value::ArpList(b)) => a == b,
_ => false, _ => false,
} }
} }
@@ -133,7 +135,7 @@ impl Value {
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::Quotation(..) => true, Value::Quotation(..) => true,
Value::CycleList(items) => !items.is_empty(), Value::CycleList(items) | Value::ArpList(items) => !items.is_empty(),
} }
} }
@@ -143,14 +145,14 @@ impl Value {
Value::Float(f, _) => f.to_string(), Value::Float(f, _) => f.to_string(),
Value::Str(s, _) => s.to_string(), Value::Str(s, _) => s.to_string(),
Value::Quotation(..) => String::new(), Value::Quotation(..) => String::new(),
Value::CycleList(_) => String::new(), Value::CycleList(_) | Value::ArpList(_) => 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::CycleList(_) => None, Value::CycleList(_) | Value::ArpList(_) => None,
} }
} }
} }

View File

@@ -190,9 +190,7 @@ impl Forth {
outputs: &mut Vec<String>, outputs: &mut Vec<String>,
cmd: &mut CmdRegister| cmd: &mut CmdRegister|
-> Result<(), String> { -> Result<(), String> {
if stack.len() < count { ensure(stack, count)?;
return Err("stack underflow".into());
}
let start = stack.len() - count; let start = stack.len() - count;
let selected = stack[start + idx].clone(); let selected = stack[start + idx].clone();
stack.truncate(start); stack.truncate(start);
@@ -216,6 +214,28 @@ impl Forth {
sound_len.max(param_max) sound_len.max(param_max)
}; };
let has_arp_list = |cmd: &CmdRegister| -> bool {
matches!(cmd.sound(), Some(Value::ArpList(_)))
|| cmd.params().iter().any(|(_, v)| matches!(v, Value::ArpList(_)))
};
let compute_arp_count = |cmd: &CmdRegister| -> usize {
let sound_len = match cmd.sound() {
Some(Value::ArpList(items)) => items.len(),
_ => 0,
};
let param_max = cmd
.params()
.iter()
.map(|(_, v)| match v {
Value::ArpList(items) => items.len(),
_ => 0,
})
.max()
.unwrap_or(0);
sound_len.max(param_max).max(1)
};
let emit_with_cycling = |cmd: &CmdRegister, let emit_with_cycling = |cmd: &CmdRegister,
emit_idx: usize, emit_idx: usize,
delta_secs: f64, delta_secs: f64,
@@ -231,7 +251,7 @@ impl Forth {
.iter() .iter()
.map(|(k, v)| { .map(|(k, v)| {
let resolved = resolve_cycling(v, emit_idx); let resolved = resolve_cycling(v, emit_idx);
if let Value::CycleList(_) = v { if let Value::CycleList(_) | Value::ArpList(_) = v {
if let Some(span) = resolved.span() { if let Some(span) = resolved.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);
@@ -258,106 +278,81 @@ impl Forth {
Op::PushStr(s, span) => stack.push(Value::Str(s.clone(), *span)), Op::PushStr(s, span) => stack.push(Value::Str(s.clone(), *span)),
Op::Dup => { Op::Dup => {
let v = stack.last().ok_or("stack underflow")?.clone(); ensure(stack, 1)?;
let v = stack.last().unwrap().clone();
stack.push(v); stack.push(v);
} }
Op::Dupn => { Op::Dupn => {
let n = stack.pop().ok_or("stack underflow")?.as_int()?; let n = pop_int(stack)?;
let v = stack.pop().ok_or("stack underflow")?; let v = pop(stack)?;
for _ in 0..n { for _ in 0..n {
stack.push(v.clone()); stack.push(v.clone());
} }
} }
Op::Drop => { Op::Drop => {
stack.pop().ok_or("stack underflow")?; pop(stack)?;
} }
Op::Swap => { Op::Swap => {
ensure(stack, 2)?;
let len = stack.len(); let len = stack.len();
if len < 2 {
return Err("stack underflow".into());
}
stack.swap(len - 1, len - 2); stack.swap(len - 1, len - 2);
} }
Op::Over => { Op::Over => {
let len = stack.len(); ensure(stack, 2)?;
if len < 2 { let v = stack[stack.len() - 2].clone();
return Err("stack underflow".into());
}
let v = stack[len - 2].clone();
stack.push(v); stack.push(v);
} }
Op::Rot => { Op::Rot => {
let len = stack.len(); ensure(stack, 3)?;
if len < 3 { let v = stack.remove(stack.len() - 3);
return Err("stack underflow".into());
}
let v = stack.remove(len - 3);
stack.push(v); stack.push(v);
} }
Op::Nip => { Op::Nip => {
let len = stack.len(); ensure(stack, 2)?;
if len < 2 { stack.remove(stack.len() - 2);
return Err("stack underflow".into());
}
stack.remove(len - 2);
} }
Op::Tuck => { Op::Tuck => {
ensure(stack, 2)?;
let len = stack.len(); let len = stack.len();
if len < 2 {
return Err("stack underflow".into());
}
let v = stack[len - 1].clone(); let v = stack[len - 1].clone();
stack.insert(len - 2, v); stack.insert(len - 2, v);
} }
Op::Dup2 => { Op::Dup2 => {
ensure(stack, 2)?;
let len = stack.len(); let len = stack.len();
if len < 2 {
return Err("stack underflow".into());
}
let a = stack[len - 2].clone(); let a = stack[len - 2].clone();
let b = stack[len - 1].clone(); let b = stack[len - 1].clone();
stack.push(a); stack.push(a);
stack.push(b); stack.push(b);
} }
Op::Drop2 => { Op::Drop2 => {
let len = stack.len(); ensure(stack, 2)?;
if len < 2 {
return Err("stack underflow".into());
}
stack.pop(); stack.pop();
stack.pop(); stack.pop();
} }
Op::Swap2 => { Op::Swap2 => {
ensure(stack, 4)?;
let len = stack.len(); let len = stack.len();
if len < 4 {
return Err("stack underflow".into());
}
stack.swap(len - 4, len - 2); stack.swap(len - 4, len - 2);
stack.swap(len - 3, len - 1); stack.swap(len - 3, len - 1);
} }
Op::Over2 => { Op::Over2 => {
ensure(stack, 4)?;
let len = stack.len(); let len = stack.len();
if len < 4 {
return Err("stack underflow".into());
}
let a = stack[len - 4].clone(); let a = stack[len - 4].clone();
let b = stack[len - 3].clone(); let b = stack[len - 3].clone();
stack.push(a); stack.push(a);
stack.push(b); stack.push(b);
} }
Op::Rev => { Op::Rev => {
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; let count = pop_int(stack)? as usize;
if count > stack.len() { ensure(stack, count)?;
return Err("stack underflow".into());
}
let start = stack.len() - count; let start = stack.len() - count;
stack[start..].reverse(); stack[start..].reverse();
} }
Op::Shuffle => { Op::Shuffle => {
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; let count = pop_int(stack)? as usize;
if count > stack.len() { ensure(stack, count)?;
return Err("stack underflow".into());
}
let start = stack.len() - count; let start = stack.len() - count;
let slice = &mut stack[start..]; let slice = &mut stack[start..];
let mut rng = self.rng.lock(); let mut rng = self.rng.lock();
@@ -367,10 +362,8 @@ impl Forth {
} }
} }
Op::Sort => { Op::Sort => {
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; let count = pop_int(stack)? as usize;
if count > stack.len() { ensure(stack, count)?;
return Err("stack underflow".into());
}
let start = stack.len() - count; let start = stack.len() - count;
stack[start..].sort_by(|a, b| { stack[start..].sort_by(|a, b| {
a.as_float() a.as_float()
@@ -380,10 +373,8 @@ impl Forth {
}); });
} }
Op::RSort => { Op::RSort => {
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; let count = pop_int(stack)? as usize;
if count > stack.len() { ensure(stack, count)?;
return Err("stack underflow".into());
}
let start = stack.len() - count; let start = stack.len() - count;
stack[start..].sort_by(|a, b| { stack[start..].sort_by(|a, b| {
b.as_float() b.as_float()
@@ -393,10 +384,8 @@ impl Forth {
}); });
} }
Op::Sum => { Op::Sum => {
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; let count = pop_int(stack)? as usize;
if count > stack.len() { ensure(stack, count)?;
return Err("stack underflow".into());
}
let start = stack.len() - count; let start = stack.len() - count;
let total: f64 = stack let total: f64 = stack
.drain(start..) .drain(start..)
@@ -405,10 +394,8 @@ impl Forth {
stack.push(float_to_value(total)); stack.push(float_to_value(total));
} }
Op::Prod => { Op::Prod => {
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; let count = pop_int(stack)? as usize;
if count > stack.len() { ensure(stack, count)?;
return Err("stack underflow".into());
}
let start = stack.len() - count; let start = stack.len() - count;
let product: f64 = stack let product: f64 = stack
.drain(start..) .drain(start..)
@@ -421,16 +408,16 @@ impl Forth {
Op::Sub => binary_op(stack, |a, b| a - b)?, Op::Sub => binary_op(stack, |a, b| a - b)?,
Op::Mul => binary_op(stack, |a, b| a * b)?, Op::Mul => binary_op(stack, |a, b| a * b)?,
Op::Div => { Op::Div => {
let b = stack.pop().ok_or("stack underflow")?; let b = pop(stack)?;
let a = stack.pop().ok_or("stack underflow")?; let a = pop(stack)?;
if b.as_float().map_or(true, |v| v == 0.0) { if b.as_float().map_or(true, |v| v == 0.0) {
return Err("division by zero".into()); return Err("division by zero".into());
} }
stack.push(lift_binary(a, b, |x, y| x / y)?); stack.push(lift_binary(a, b, |x, y| x / y)?);
} }
Op::Mod => { Op::Mod => {
let b = stack.pop().ok_or("stack underflow")?; let b = pop(stack)?;
let a = stack.pop().ok_or("stack underflow")?; let a = pop(stack)?;
if b.as_float().map_or(true, |v| v == 0.0) { if b.as_float().map_or(true, |v| v == 0.0) {
return Err("modulo by zero".into()); return Err("modulo by zero".into());
} }
@@ -438,42 +425,42 @@ impl Forth {
stack.push(result); stack.push(result);
} }
Op::Neg => { Op::Neg => {
let v = stack.pop().ok_or("stack underflow")?; let v = pop(stack)?;
stack.push(lift_unary(v, |x| -x)?); stack.push(lift_unary(v, |x| -x)?);
} }
Op::Abs => { Op::Abs => {
let v = stack.pop().ok_or("stack underflow")?; let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.abs())?); stack.push(lift_unary(v, |x| x.abs())?);
} }
Op::Floor => { Op::Floor => {
let v = stack.pop().ok_or("stack underflow")?; let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.floor())?); stack.push(lift_unary(v, |x| x.floor())?);
} }
Op::Ceil => { Op::Ceil => {
let v = stack.pop().ok_or("stack underflow")?; let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.ceil())?); stack.push(lift_unary(v, |x| x.ceil())?);
} }
Op::Round => { Op::Round => {
let v = stack.pop().ok_or("stack underflow")?; let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.round())?); stack.push(lift_unary(v, |x| x.round())?);
} }
Op::Min => binary_op(stack, |a, b| a.min(b))?, Op::Min => binary_op(stack, |a, b| a.min(b))?,
Op::Max => binary_op(stack, |a, b| a.max(b))?, Op::Max => binary_op(stack, |a, b| a.max(b))?,
Op::Pow => binary_op(stack, |a, b| a.powf(b))?, Op::Pow => binary_op(stack, |a, b| a.powf(b))?,
Op::Sqrt => { Op::Sqrt => {
let v = stack.pop().ok_or("stack underflow")?; let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.sqrt())?); stack.push(lift_unary(v, |x| x.sqrt())?);
} }
Op::Sin => { Op::Sin => {
let v = stack.pop().ok_or("stack underflow")?; let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.sin())?); stack.push(lift_unary(v, |x| x.sin())?);
} }
Op::Cos => { Op::Cos => {
let v = stack.pop().ok_or("stack underflow")?; let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.cos())?); stack.push(lift_unary(v, |x| x.cos())?);
} }
Op::Log => { Op::Log => {
let v = stack.pop().ok_or("stack underflow")?; let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.ln())?); stack.push(lift_unary(v, |x| x.ln())?);
} }
@@ -485,37 +472,37 @@ impl Forth {
Op::Ge => cmp_op(stack, |a, b| a >= b)?, Op::Ge => cmp_op(stack, |a, b| a >= b)?,
Op::And => { Op::And => {
let b = stack.pop().ok_or("stack underflow")?.is_truthy(); let b = pop_bool(stack)?;
let a = stack.pop().ok_or("stack underflow")?.is_truthy(); let a = pop_bool(stack)?;
stack.push(Value::Int(if a && b { 1 } else { 0 }, None)); stack.push(Value::Int(if a && b { 1 } else { 0 }, None));
} }
Op::Or => { Op::Or => {
let b = stack.pop().ok_or("stack underflow")?.is_truthy(); let b = pop_bool(stack)?;
let a = stack.pop().ok_or("stack underflow")?.is_truthy(); let a = pop_bool(stack)?;
stack.push(Value::Int(if a || b { 1 } else { 0 }, None)); stack.push(Value::Int(if a || b { 1 } else { 0 }, None));
} }
Op::Not => { Op::Not => {
let v = stack.pop().ok_or("stack underflow")?.is_truthy(); let v = pop_bool(stack)?;
stack.push(Value::Int(if v { 0 } else { 1 }, None)); stack.push(Value::Int(if v { 0 } else { 1 }, None));
} }
Op::Xor => { Op::Xor => {
let b = stack.pop().ok_or("stack underflow")?.is_truthy(); let b = pop_bool(stack)?;
let a = stack.pop().ok_or("stack underflow")?.is_truthy(); let a = pop_bool(stack)?;
stack.push(Value::Int(if a ^ b { 1 } else { 0 }, None)); stack.push(Value::Int(if a ^ b { 1 } else { 0 }, None));
} }
Op::Nand => { Op::Nand => {
let b = stack.pop().ok_or("stack underflow")?.is_truthy(); let b = pop_bool(stack)?;
let a = stack.pop().ok_or("stack underflow")?.is_truthy(); let a = pop_bool(stack)?;
stack.push(Value::Int(if !(a && b) { 1 } else { 0 }, None)); stack.push(Value::Int(if !(a && b) { 1 } else { 0 }, None));
} }
Op::Nor => { Op::Nor => {
let b = stack.pop().ok_or("stack underflow")?.is_truthy(); let b = pop_bool(stack)?;
let a = stack.pop().ok_or("stack underflow")?.is_truthy(); let a = pop_bool(stack)?;
stack.push(Value::Int(if !(a || b) { 1 } else { 0 }, None)); stack.push(Value::Int(if !(a || b) { 1 } else { 0 }, None));
} }
Op::BranchIfZero(offset, then_span, else_span) => { Op::BranchIfZero(offset, then_span, else_span) => {
let v = stack.pop().ok_or("stack underflow")?; let v = pop(stack)?;
if !v.is_truthy() { if !v.is_truthy() {
if let Some(span) = else_span { if let Some(span) = else_span {
if let Some(trace) = trace_cell.borrow_mut().as_mut() { if let Some(trace) = trace_cell.borrow_mut().as_mut() {
@@ -534,9 +521,7 @@ impl Forth {
} }
Op::NewCmd => { Op::NewCmd => {
if stack.is_empty() { ensure(stack, 1)?;
return Err("stack underflow".into());
}
let values = std::mem::take(stack); let values = std::mem::take(stack);
let val = if values.len() == 1 { let val = if values.len() == 1 {
values.into_iter().next().unwrap() values.into_iter().next().unwrap()
@@ -546,9 +531,7 @@ impl Forth {
cmd.set_sound(val); cmd.set_sound(val);
} }
Op::SetParam(param) => { Op::SetParam(param) => {
if stack.is_empty() { ensure(stack, 1)?;
return Err("stack underflow".into());
}
let values = std::mem::take(stack); let values = std::mem::take(stack);
let val = if values.len() == 1 { let val = if values.len() == 1 {
values.into_iter().next().unwrap() values.into_iter().next().unwrap()
@@ -559,24 +542,36 @@ impl Forth {
} }
Op::Emit => { Op::Emit => {
let poly_count = compute_poly_count(cmd); if has_arp_list(cmd) {
let deltas = if cmd.deltas().is_empty() { let arp_count = compute_arp_count(cmd);
vec![Value::Float(0.0, None)] let explicit_deltas = !cmd.deltas().is_empty();
} else { let delta_list: Vec<Value> = if explicit_deltas {
cmd.deltas().to_vec() cmd.deltas().to_vec()
}; } else {
Vec::new()
};
let count = if explicit_deltas {
arp_count.max(delta_list.len())
} else {
arp_count
};
for poly_idx in 0..poly_count { for i in 0..count {
for delta_val in deltas.iter() { let delta_secs = if explicit_deltas {
let delta_frac = delta_val.as_float()?; let dv = &delta_list[i % delta_list.len()];
let delta_secs = ctx.nudge_secs + delta_frac * ctx.step_duration(); let frac = dv.as_float()?;
if let Some(span) = delta_val.span() { if let Some(span) = dv.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);
}
} }
} ctx.nudge_secs + frac * ctx.step_duration()
} else {
ctx.nudge_secs
+ (i as f64 / count as f64) * ctx.step_duration()
};
if let Some(sound_val) = if let Some(sound_val) =
emit_with_cycling(cmd, poly_idx, delta_secs, outputs)? emit_with_cycling(cmd, i, delta_secs, outputs)?
{ {
if let Some(span) = sound_val.span() { if let Some(span) = sound_val.span() {
if let Some(trace) = trace_cell.borrow_mut().as_mut() { if let Some(trace) = trace_cell.borrow_mut().as_mut() {
@@ -585,11 +580,42 @@ impl Forth {
} }
} }
} }
} else {
let poly_count = compute_poly_count(cmd);
let deltas = if cmd.deltas().is_empty() {
vec![Value::Float(0.0, None)]
} else {
cmd.deltas().to_vec()
};
for poly_idx in 0..poly_count {
for delta_val in deltas.iter() {
let delta_frac = delta_val.as_float()?;
let delta_secs =
ctx.nudge_secs + delta_frac * ctx.step_duration();
if let Some(span) = delta_val.span() {
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
trace.selected_spans.push(span);
}
}
if let Some(sound_val) =
emit_with_cycling(cmd, poly_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);
}
}
}
}
}
} }
} }
Op::Get => { Op::Get => {
let name = stack.pop().ok_or("stack underflow")?; let name = pop(stack)?;
let name = name.as_str()?; let name = name.as_str()?;
let vw = var_writes_cell.borrow(); let vw = var_writes_cell.borrow();
let vw_ref = vw.as_ref().expect("var_writes taken"); let vw_ref = vw.as_ref().expect("var_writes taken");
@@ -602,9 +628,9 @@ impl Forth {
stack.push(val); stack.push(val);
} }
Op::Set => { Op::Set => {
let name = stack.pop().ok_or("stack underflow")?; let name = pop(stack)?;
let name = name.as_str()?.to_string(); let name = name.as_str()?.to_string();
let val = stack.pop().ok_or("stack underflow")?; let val = pop(stack)?;
var_writes_cell var_writes_cell
.borrow_mut() .borrow_mut()
.as_mut() .as_mut()
@@ -638,8 +664,8 @@ impl Forth {
} }
Op::Rand(word_span) => { Op::Rand(word_span) => {
let b = stack.pop().ok_or("stack underflow")?; let b = pop(stack)?;
let a = stack.pop().ok_or("stack underflow")?; let a = pop(stack)?;
match (&a, &b) { match (&a, &b) {
(Value::Int(a_i, _), Value::Int(b_i, _)) => { (Value::Int(a_i, _), Value::Int(b_i, _)) => {
let (lo, hi) = if a_i <= b_i { let (lo, hi) = if a_i <= b_i {
@@ -666,8 +692,8 @@ impl Forth {
} }
} }
Op::ExpRand(word_span) => { Op::ExpRand(word_span) => {
let hi = stack.pop().ok_or("stack underflow")?.as_float()?; let hi = pop_float(stack)?;
let lo = stack.pop().ok_or("stack underflow")?.as_float()?; let lo = pop_float(stack)?;
if lo <= 0.0 || hi <= 0.0 { if lo <= 0.0 || hi <= 0.0 {
return Err("exprand requires positive values".into()); return Err("exprand requires positive values".into());
} }
@@ -678,8 +704,8 @@ impl Forth {
stack.push(Value::Float(val, None)); stack.push(Value::Float(val, None));
} }
Op::LogRand(word_span) => { Op::LogRand(word_span) => {
let hi = stack.pop().ok_or("stack underflow")?.as_float()?; let hi = pop_float(stack)?;
let lo = stack.pop().ok_or("stack underflow")?.as_float()?; let lo = pop_float(stack)?;
if lo <= 0.0 || hi <= 0.0 { if lo <= 0.0 || hi <= 0.0 {
return Err("logrand requires positive values".into()); return Err("logrand requires positive values".into());
} }
@@ -690,12 +716,12 @@ impl Forth {
stack.push(Value::Float(val, None)); stack.push(Value::Float(val, None));
} }
Op::Seed => { Op::Seed => {
let s = stack.pop().ok_or("stack underflow")?.as_int()?; let s = pop_int(stack)?;
*self.rng.lock() = StdRng::seed_from_u64(s as u64); *self.rng.lock() = StdRng::seed_from_u64(s as u64);
} }
Op::Cycle(word_span) | Op::PCycle(word_span) => { Op::Cycle(word_span) | Op::PCycle(word_span) => {
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; let count = pop_int(stack)? as usize;
if count == 0 { if count == 0 {
return Err("cycle count must be > 0".into()); return Err("cycle count must be > 0".into());
} }
@@ -714,7 +740,7 @@ impl Forth {
} }
Op::Choose(word_span) => { Op::Choose(word_span) => {
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; let count = pop_int(stack)? as usize;
if count == 0 { if count == 0 {
return Err("choose count must be > 0".into()); return Err("choose count must be > 0".into());
} }
@@ -730,7 +756,7 @@ impl Forth {
} }
Op::Bounce(word_span) => { Op::Bounce(word_span) => {
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; let count = pop_int(stack)? as usize;
if count == 0 { if count == 0 {
return Err("bounce count must be > 0".into()); return Err("bounce count must be > 0".into());
} }
@@ -752,14 +778,12 @@ impl Forth {
} }
Op::WChoose(word_span) => { Op::WChoose(word_span) => {
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize; let count = pop_int(stack)? as usize;
if count == 0 { if count == 0 {
return Err("wchoose count must be > 0".into()); return Err("wchoose count must be > 0".into());
} }
let pairs_needed = count * 2; let pairs_needed = count * 2;
if stack.len() < pairs_needed { ensure(stack, pairs_needed)?;
return Err("stack underflow".into());
}
let start = stack.len() - pairs_needed; let start = stack.len() - pairs_needed;
let mut values = Vec::with_capacity(count); let mut values = Vec::with_capacity(count);
let mut weights = Vec::with_capacity(count); let mut weights = Vec::with_capacity(count);
@@ -793,8 +817,8 @@ impl Forth {
} }
Op::ChanceExec(word_span) | Op::ProbExec(word_span) => { Op::ChanceExec(word_span) | Op::ProbExec(word_span) => {
let threshold = stack.pop().ok_or("stack underflow")?.as_float()?; let threshold = pop_float(stack)?;
let quot = stack.pop().ok_or("stack underflow")?; let quot = pop(stack)?;
let val: f64 = self.rng.lock().gen(); let val: f64 = self.rng.lock().gen();
let limit = match &ops[pc] { let limit = match &ops[pc] {
Op::ChanceExec(_) => threshold, Op::ChanceExec(_) => threshold,
@@ -815,7 +839,7 @@ impl Forth {
} }
Op::Every => { Op::Every => {
let n = stack.pop().ok_or("stack underflow")?.as_int()?; let n = pop_int(stack)?;
if n <= 0 { if n <= 0 {
return Err("every count must be > 0".into()); return Err("every count must be > 0".into());
} }
@@ -828,8 +852,8 @@ impl Forth {
} }
Op::When | Op::Unless => { Op::When | Op::Unless => {
let cond = stack.pop().ok_or("stack underflow")?; let cond = pop(stack)?;
let quot = stack.pop().ok_or("stack underflow")?; let quot = pop(stack)?;
let should_run = match &ops[pc] { let should_run = match &ops[pc] {
Op::When => cond.is_truthy(), Op::When => cond.is_truthy(),
_ => !cond.is_truthy(), _ => !cond.is_truthy(),
@@ -840,9 +864,9 @@ impl Forth {
} }
Op::IfElse => { Op::IfElse => {
let cond = stack.pop().ok_or("stack underflow")?; let cond = pop(stack)?;
let false_quot = stack.pop().ok_or("stack underflow")?; let false_quot = pop(stack)?;
let true_quot = stack.pop().ok_or("stack underflow")?; let true_quot = pop(stack)?;
let quot = if cond.is_truthy() { let quot = if cond.is_truthy() {
true_quot true_quot
} else { } else {
@@ -852,7 +876,7 @@ impl Forth {
} }
Op::Pick => { Op::Pick => {
let idx_i = stack.pop().ok_or("stack underflow")?.as_int()?; let idx_i = pop_int(stack)?;
if idx_i < 0 { if idx_i < 0 {
return Err(format!("pick index must be >= 0, got {idx_i}")); return Err(format!("pick index must be >= 0, got {idx_i}"));
} }
@@ -879,13 +903,13 @@ impl Forth {
} }
Op::Mtof => { Op::Mtof => {
let note = stack.pop().ok_or("stack underflow")?.as_float()?; let note = pop_float(stack)?;
let freq = 440.0 * 2.0_f64.powf((note - 69.0) / 12.0); let freq = 440.0 * 2.0_f64.powf((note - 69.0) / 12.0);
stack.push(Value::Float(freq, None)); stack.push(Value::Float(freq, None));
} }
Op::Ftom => { Op::Ftom => {
let freq = stack.pop().ok_or("stack underflow")?.as_float()?; let freq = pop_float(stack)?;
let note = 69.0 + 12.0 * (freq / 440.0).log2(); let note = 69.0 + 12.0 * (freq / 440.0).log2();
stack.push(Value::Float(note, None)); stack.push(Value::Float(note, None));
} }
@@ -894,7 +918,7 @@ impl Forth {
if pattern.is_empty() { if pattern.is_empty() {
return Err("empty scale pattern".into()); return Err("empty scale pattern".into());
} }
let val = stack.pop().ok_or("stack underflow")?; let val = pop(stack)?;
let len = pattern.len() as i64; let len = pattern.len() as i64;
let result = lift_unary_int(val, |degree| { let result = lift_unary_int(val, |degree| {
let octave_offset = degree.div_euclid(len); let octave_offset = degree.div_euclid(len);
@@ -905,21 +929,21 @@ impl Forth {
} }
Op::Chord(intervals) => { Op::Chord(intervals) => {
let root = stack.pop().ok_or("stack underflow")?.as_int()?; let root = pop_int(stack)?;
for &interval in *intervals { for &interval in *intervals {
stack.push(Value::Int(root + interval, None)); stack.push(Value::Int(root + interval, None));
} }
} }
Op::Oct => { Op::Oct => {
let shift = stack.pop().ok_or("stack underflow")?; let shift = pop(stack)?;
let note = stack.pop().ok_or("stack underflow")?; let note = pop(stack)?;
let result = lift_binary(note, shift, |n, s| n + s * 12.0)?; let result = lift_binary(note, shift, |n, s| n + s * 12.0)?;
stack.push(result); stack.push(result);
} }
Op::SetTempo => { Op::SetTempo => {
let tempo = stack.pop().ok_or("stack underflow")?.as_float()?; let tempo = pop_float(stack)?;
let clamped = tempo.clamp(20.0, 300.0); let clamped = tempo.clamp(20.0, 300.0);
var_writes_cell var_writes_cell
.borrow_mut() .borrow_mut()
@@ -929,7 +953,7 @@ impl Forth {
} }
Op::SetSpeed => { Op::SetSpeed => {
let speed = stack.pop().ok_or("stack underflow")?.as_float()?; let speed = pop_float(stack)?;
let clamped = speed.clamp(0.125, 8.0); let clamped = speed.clamp(0.125, 8.0);
var_writes_cell var_writes_cell
.borrow_mut() .borrow_mut()
@@ -939,8 +963,8 @@ impl Forth {
} }
Op::Chain => { Op::Chain => {
let pattern = stack.pop().ok_or("stack underflow")?.as_int()? - 1; let pattern = pop_int(stack)? - 1;
let bank = stack.pop().ok_or("stack underflow")?.as_int()? - 1; let bank = pop_int(stack)? - 1;
if bank < 0 || pattern < 0 { if bank < 0 || pattern < 0 {
return Err("chain: bank and pattern must be >= 1".into()); return Err("chain: bank and pattern must be >= 1".into());
} }
@@ -959,7 +983,7 @@ impl Forth {
} }
Op::Loop => { Op::Loop => {
let beats = stack.pop().ok_or("stack underflow")?.as_float()?; let beats = pop_float(stack)?;
if ctx.tempo == 0.0 || ctx.speed == 0.0 { if ctx.tempo == 0.0 || ctx.speed == 0.0 {
return Err("tempo and speed must be non-zero".into()); return Err("tempo and speed must be non-zero".into());
} }
@@ -969,18 +993,22 @@ impl Forth {
} }
Op::At => { Op::At => {
if stack.is_empty() { ensure(stack, 1)?;
return Err("stack underflow".into());
}
let deltas = std::mem::take(stack); let deltas = std::mem::take(stack);
cmd.set_deltas(deltas); cmd.set_deltas(deltas);
} }
Op::Arp => {
ensure(stack, 1)?;
let values = std::mem::take(stack);
stack.push(Value::ArpList(Arc::from(values)));
}
Op::Adsr => { Op::Adsr => {
let r = stack.pop().ok_or("stack underflow")?; let r = pop(stack)?;
let s = stack.pop().ok_or("stack underflow")?; let s = pop(stack)?;
let d = stack.pop().ok_or("stack underflow")?; let d = pop(stack)?;
let a = stack.pop().ok_or("stack underflow")?; let a = pop(stack)?;
cmd.set_param("attack", a); cmd.set_param("attack", a);
cmd.set_param("decay", d); cmd.set_param("decay", d);
cmd.set_param("sustain", s); cmd.set_param("sustain", s);
@@ -988,41 +1016,41 @@ impl Forth {
} }
Op::Ad => { Op::Ad => {
let d = stack.pop().ok_or("stack underflow")?; let d = pop(stack)?;
let a = stack.pop().ok_or("stack underflow")?; let a = pop(stack)?;
cmd.set_param("attack", a); cmd.set_param("attack", a);
cmd.set_param("decay", d); cmd.set_param("decay", d);
cmd.set_param("sustain", Value::Int(0, None)); cmd.set_param("sustain", Value::Int(0, None));
} }
Op::Apply => { Op::Apply => {
let quot = stack.pop().ok_or("stack underflow")?; let quot = pop(stack)?;
run_quotation(quot, stack, outputs, cmd)?; run_quotation(quot, stack, outputs, cmd)?;
} }
Op::Ramp => { Op::Ramp => {
let curve = stack.pop().ok_or("stack underflow")?.as_float()?; let curve = pop_float(stack)?;
let freq = stack.pop().ok_or("stack underflow")?.as_float()?; let freq = pop_float(stack)?;
let phase = (freq * ctx.beat).fract(); let phase = (freq * ctx.beat).fract();
let phase = if phase < 0.0 { phase + 1.0 } else { phase }; let phase = if phase < 0.0 { phase + 1.0 } else { phase };
let val = phase.powf(curve); let val = phase.powf(curve);
stack.push(Value::Float(val, None)); stack.push(Value::Float(val, None));
} }
Op::Triangle => { Op::Triangle => {
let freq = stack.pop().ok_or("stack underflow")?.as_float()?; let freq = pop_float(stack)?;
let phase = (freq * ctx.beat).fract(); let phase = (freq * ctx.beat).fract();
let phase = if phase < 0.0 { phase + 1.0 } else { phase }; let phase = if phase < 0.0 { phase + 1.0 } else { phase };
let val = 1.0 - (2.0 * phase - 1.0).abs(); let val = 1.0 - (2.0 * phase - 1.0).abs();
stack.push(Value::Float(val, None)); stack.push(Value::Float(val, None));
} }
Op::Range => { Op::Range => {
let max = stack.pop().ok_or("stack underflow")?.as_float()?; let max = pop_float(stack)?;
let min = stack.pop().ok_or("stack underflow")?.as_float()?; let min = pop_float(stack)?;
let val = stack.pop().ok_or("stack underflow")?.as_float()?; let val = pop_float(stack)?;
stack.push(Value::Float(min + val * (max - min), None)); stack.push(Value::Float(min + val * (max - min), None));
} }
Op::Perlin => { Op::Perlin => {
let freq = stack.pop().ok_or("stack underflow")?.as_float()?; let freq = pop_float(stack)?;
let val = perlin_noise_1d(freq * ctx.beat); let val = perlin_noise_1d(freq * ctx.beat);
stack.push(Value::Float(val, None)); stack.push(Value::Float(val, None));
} }
@@ -1032,8 +1060,8 @@ impl Forth {
} }
Op::IntRange => { Op::IntRange => {
let end = stack.pop().ok_or("stack underflow")?.as_int()?; let end = pop_int(stack)?;
let start = stack.pop().ok_or("stack underflow")?.as_int()?; let start = pop_int(stack)?;
if start <= end { if start <= end {
for i in start..=end { for i in start..=end {
stack.push(Value::Int(i, None)); stack.push(Value::Int(i, None));
@@ -1046,9 +1074,9 @@ impl Forth {
} }
Op::StepRange => { Op::StepRange => {
let step = stack.pop().ok_or("stack underflow")?.as_float()?; let step = pop_float(stack)?;
let end = stack.pop().ok_or("stack underflow")?.as_float()?; let end = pop_float(stack)?;
let start = stack.pop().ok_or("stack underflow")?.as_float()?; let start = pop_float(stack)?;
if step == 0.0 { if step == 0.0 {
return Err("step cannot be zero".into()); return Err("step cannot be zero".into());
} }
@@ -1065,8 +1093,8 @@ impl Forth {
} }
Op::Generate => { Op::Generate => {
let count = stack.pop().ok_or("stack underflow")?.as_int()?; let count = pop_int(stack)?;
let quot = stack.pop().ok_or("stack underflow")?; let quot = pop(stack)?;
if count < 0 { if count < 0 {
return Err("gen count must be >= 0".into()); return Err("gen count must be >= 0".into());
} }
@@ -1081,8 +1109,8 @@ impl Forth {
} }
Op::Times => { Op::Times => {
let quot = stack.pop().ok_or("stack underflow")?; let quot = pop(stack)?;
let count = stack.pop().ok_or("stack underflow")?.as_int()?; let count = pop_int(stack)?;
if count < 0 { if count < 0 {
return Err("times count must be >= 0".into()); return Err("times count must be >= 0".into());
} }
@@ -1097,9 +1125,9 @@ impl Forth {
} }
Op::GeomRange => { Op::GeomRange => {
let count = stack.pop().ok_or("stack underflow")?.as_int()?; let count = pop_int(stack)?;
let ratio = stack.pop().ok_or("stack underflow")?.as_float()?; let ratio = pop_float(stack)?;
let start = stack.pop().ok_or("stack underflow")?.as_float()?; let start = pop_float(stack)?;
if count < 0 { if count < 0 {
return Err("geom.. count must be >= 0".into()); return Err("geom.. count must be >= 0".into());
} }
@@ -1111,8 +1139,8 @@ impl Forth {
} }
Op::Euclid => { Op::Euclid => {
let n = stack.pop().ok_or("stack underflow")?.as_int()?; let n = pop_int(stack)?;
let k = stack.pop().ok_or("stack underflow")?.as_int()?; let k = pop_int(stack)?;
if k < 0 || n < 0 { if k < 0 || n < 0 {
return Err("euclid: k and n must be >= 0".into()); return Err("euclid: k and n must be >= 0".into());
} }
@@ -1122,9 +1150,9 @@ impl Forth {
} }
Op::EuclidRot => { Op::EuclidRot => {
let r = stack.pop().ok_or("stack underflow")?.as_int()?; let r = pop_int(stack)?;
let n = stack.pop().ok_or("stack underflow")?.as_int()?; let n = pop_int(stack)?;
let k = stack.pop().ok_or("stack underflow")?.as_int()?; let k = pop_int(stack)?;
if k < 0 || n < 0 || r < 0 { if k < 0 || n < 0 || r < 0 {
return Err("euclidrot: k, n, and r must be >= 0".into()); return Err("euclidrot: k, n, and r must be >= 0".into());
} }
@@ -1134,33 +1162,31 @@ impl Forth {
} }
Op::ModLfo(shape) => { Op::ModLfo(shape) => {
let period = stack.pop().ok_or("stack underflow")?.as_float()? * ctx.step_duration(); let period = pop_float(stack)? * ctx.step_duration();
let max = stack.pop().ok_or("stack underflow")?.as_float()?; let max = pop_float(stack)?;
let min = stack.pop().ok_or("stack underflow")?.as_float()?; let min = pop_float(stack)?;
let suffix = match shape { 1 => "t", 2 => "w", 3 => "q", _ => "" }; let suffix = match shape { 1 => "t", 2 => "w", 3 => "q", _ => "" };
let s = format!("{min}~{max}:{period}{suffix}"); let s = format!("{min}~{max}:{period}{suffix}");
stack.push(Value::Str(s.into(), None)); stack.push(Value::Str(s.into(), None));
} }
Op::ModSlide(curve) => { Op::ModSlide(curve) => {
let dur = stack.pop().ok_or("stack underflow")?.as_float()? * ctx.step_duration(); let dur = pop_float(stack)? * ctx.step_duration();
let end = stack.pop().ok_or("stack underflow")?.as_float()?; let end = pop_float(stack)?;
let start = stack.pop().ok_or("stack underflow")?.as_float()?; let start = pop_float(stack)?;
let suffix = match curve { 1 => "e", 2 => "s", _ => "" }; let suffix = match curve { 1 => "e", 2 => "s", _ => "" };
let s = format!("{start}>{end}:{dur}{suffix}"); let s = format!("{start}>{end}:{dur}{suffix}");
stack.push(Value::Str(s.into(), None)); stack.push(Value::Str(s.into(), None));
} }
Op::ModRnd(dist) => { Op::ModRnd(dist) => {
let period = stack.pop().ok_or("stack underflow")?.as_float()? * ctx.step_duration(); let period = pop_float(stack)? * ctx.step_duration();
let max = stack.pop().ok_or("stack underflow")?.as_float()?; let max = pop_float(stack)?;
let min = stack.pop().ok_or("stack underflow")?.as_float()?; let min = pop_float(stack)?;
let suffix = match dist { 1 => "s", 2 => "d", _ => "" }; let suffix = match dist { 1 => "s", 2 => "d", _ => "" };
let s = format!("{min}?{max}:{period}{suffix}"); let s = format!("{min}?{max}:{period}{suffix}");
stack.push(Value::Str(s.into(), None)); stack.push(Value::Str(s.into(), None));
} }
Op::ModEnv => { Op::ModEnv => {
if stack.is_empty() { ensure(stack, 1)?;
return Err("stack underflow".into());
}
let values = std::mem::take(stack); let values = std::mem::take(stack);
let mut floats = Vec::with_capacity(values.len()); let mut floats = Vec::with_capacity(values.len());
for v in &values { for v in &values {
@@ -1246,8 +1272,8 @@ impl Forth {
outputs.push(format!("/midi/continue/dev/{dev}")); outputs.push(format!("/midi/continue/dev/{dev}"));
} }
Op::GetMidiCC => { Op::GetMidiCC => {
let chan = stack.pop().ok_or("stack underflow")?.as_int()?; let chan = pop_int(stack)?;
let cc = stack.pop().ok_or("stack underflow")?.as_int()?; let cc = pop_int(stack)?;
let cc_clamped = (cc.clamp(0, 127)) as usize; let cc_clamped = (cc.clamp(0, 127)) as usize;
let chan_clamped = (chan.clamp(1, 16) - 1) as usize; let chan_clamped = (chan.clamp(1, 16) - 1) as usize;
let (_, params) = cmd.snapshot().unwrap_or((None, &[])); let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
@@ -1260,8 +1286,8 @@ impl Forth {
stack.push(Value::Int(val as i64, None)); stack.push(Value::Int(val as i64, None));
} }
Op::Forget => { Op::Forget => {
let name = stack.pop().ok_or("stack underflow")?.as_str()?.to_string(); let name = pop(stack)?;
self.dict.lock().remove(&name); self.dict.lock().remove(name.as_str()?);
} }
} }
pc += 1; pc += 1;
@@ -1333,8 +1359,6 @@ fn is_tempo_scaled_param(name: &str) -> bool {
| "fmd" | "fmd"
| "fmr" | "fmr"
| "glide" | "glide"
| "verbdecay"
| "verbpredelay"
| "chorusdelay" | "chorusdelay"
| "duration" | "duration"
) )
@@ -1472,6 +1496,29 @@ fn perlin_noise_1d(x: f64) -> f64 {
(d0 + s * (d1 - d0)) * 0.5 + 0.5 (d0 + s * (d1 - d0)) * 0.5 + 0.5
} }
fn pop(stack: &mut Vec<Value>) -> Result<Value, String> {
stack.pop().ok_or_else(|| "stack underflow".to_string())
}
fn pop_int(stack: &mut Vec<Value>) -> Result<i64, String> {
pop(stack)?.as_int()
}
fn pop_float(stack: &mut Vec<Value>) -> Result<f64, String> {
pop(stack)?.as_float()
}
fn pop_bool(stack: &mut Vec<Value>) -> Result<bool, String> {
Ok(pop(stack)?.is_truthy())
}
fn ensure(stack: &[Value], n: usize) -> Result<(), String> {
if stack.len() < n {
return Err("stack underflow".into());
}
Ok(())
}
fn float_to_value(result: f64) -> Value { fn float_to_value(result: f64) -> Value {
if result.fract() == 0.0 && result.abs() < i64::MAX as f64 { if result.fract() == 0.0 && result.abs() < i64::MAX as f64 {
Value::Int(result as i64, None) Value::Int(result as i64, None)
@@ -1505,8 +1552,8 @@ fn binary_op<F>(stack: &mut Vec<Value>, f: F) -> Result<(), String>
where where
F: Fn(f64, f64) -> f64 + Copy, F: Fn(f64, f64) -> f64 + Copy,
{ {
let b = stack.pop().ok_or("stack underflow")?; let b = pop(stack)?;
let a = stack.pop().ok_or("stack underflow")?; let a = pop(stack)?;
stack.push(lift_binary(a, b, f)?); stack.push(lift_binary(a, b, f)?);
Ok(()) Ok(())
} }
@@ -1515,8 +1562,8 @@ fn cmp_op<F>(stack: &mut Vec<Value>, f: F) -> Result<(), String>
where where
F: Fn(f64, f64) -> bool, F: Fn(f64, f64) -> bool,
{ {
let b = stack.pop().ok_or("stack underflow")?; let b = pop(stack)?;
let a = stack.pop().ok_or("stack underflow")?; let a = pop(stack)?;
let result = if f(a.as_float()?, b.as_float()?) { let result = if f(a.as_float()?, b.as_float()?) {
1 1
} else { } else {
@@ -1528,7 +1575,7 @@ where
fn resolve_cycling(val: &Value, emit_idx: usize) -> Cow<'_, Value> { fn resolve_cycling(val: &Value, emit_idx: usize) -> Cow<'_, Value> {
match val { match val {
Value::CycleList(items) if !items.is_empty() => { Value::CycleList(items) | Value::ArpList(items) if !items.is_empty() => {
Cow::Owned(items[emit_idx % items.len()].clone()) Cow::Owned(items[emit_idx % items.len()].clone())
} }
other => Cow::Borrowed(other), other => Cow::Borrowed(other),

View File

@@ -79,6 +79,7 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"tempo!" => Op::SetTempo, "tempo!" => Op::SetTempo,
"speed!" => Op::SetSpeed, "speed!" => Op::SetSpeed,
"at" => Op::At, "at" => Op::At,
"arp" => Op::Arp,
"adsr" => Op::Adsr, "adsr" => Op::Adsr,
"ad" => Op::Ad, "ad" => Op::Ad,
"apply" => Op::Apply, "apply" => Op::Apply,

File diff suppressed because it is too large Load Diff

View File

@@ -6,61 +6,61 @@ pub(super) const WORDS: &[Word] = &[
name: "chan", name: "chan",
aliases: &[], aliases: &[],
category: "MIDI", category: "MIDI",
stack: "(n --)", stack: "(v.. --)",
desc: "Set MIDI channel 1-16", desc: "Set MIDI channel 1-16",
example: "1 chan", example: "1 chan",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "ccnum", name: "ccnum",
aliases: &[], aliases: &[],
category: "MIDI", category: "MIDI",
stack: "(n --)", stack: "(v.. --)",
desc: "Set MIDI CC number 0-127", desc: "Set MIDI CC number 0-127",
example: "1 ccnum", example: "1 ccnum",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "ccout", name: "ccout",
aliases: &[], aliases: &[],
category: "MIDI", category: "MIDI",
stack: "(n --)", stack: "(v.. --)",
desc: "Set MIDI CC output value 0-127", desc: "Set MIDI CC output value 0-127",
example: "64 ccout", example: "64 ccout",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "bend", name: "bend",
aliases: &[], aliases: &[],
category: "MIDI", category: "MIDI",
stack: "(f --)", stack: "(v.. --)",
desc: "Set pitch bend -1.0 to 1.0 (0 = center)", desc: "Set pitch bend -1.0 to 1.0 (0 = center)",
example: "0.5 bend", example: "0.5 bend",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "pressure", name: "pressure",
aliases: &[], aliases: &[],
category: "MIDI", category: "MIDI",
stack: "(n --)", stack: "(v.. --)",
desc: "Set channel pressure (aftertouch) 0-127", desc: "Set channel pressure (aftertouch) 0-127",
example: "64 pressure", example: "64 pressure",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "program", name: "program",
aliases: &[], aliases: &[],
category: "MIDI", category: "MIDI",
stack: "(n --)", stack: "(v.. --)",
desc: "Set program change number 0-127", desc: "Set program change number 0-127",
example: "0 program", example: "0 program",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "m.", name: "m.",
@@ -126,10 +126,10 @@ pub(super) const WORDS: &[Word] = &[
name: "dev", name: "dev",
aliases: &[], aliases: &[],
category: "MIDI", category: "MIDI",
stack: "(n --)", stack: "(v.. --)",
desc: "Set MIDI device slot 0-3 for output/input", desc: "Set MIDI device slot 0-3 for output/input",
example: "1 dev 60 note m.", example: "1 dev 60 note m.",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
]; ];

View File

@@ -23,6 +23,16 @@ pub(super) const WORDS: &[Word] = &[
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },
Word {
name: "arp",
aliases: &[],
category: "Sound",
stack: "(v1..vn -- arplist)",
desc: "Wrap stack values as arpeggio list for spreading across deltas",
example: "c4 e4 g4 b4 arp note => arpeggio",
compile: Simple,
varargs: true,
},
Word { Word {
name: "clear", name: "clear",
aliases: &[], aliases: &[],
@@ -38,555 +48,555 @@ pub(super) const WORDS: &[Word] = &[
name: "bank", name: "bank",
aliases: &[], aliases: &[],
category: "Sample", category: "Sample",
stack: "(str --)", stack: "(v.. --)",
desc: "Set sample bank suffix", desc: "Set sample bank suffix",
example: "\"a\" bank", example: "\"a\" bank",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "time", name: "time",
aliases: &[], aliases: &[],
category: "Sample", category: "Sample",
stack: "(f --)", stack: "(v.. --)",
desc: "Set time offset", desc: "Set time offset",
example: "0.1 time", example: "0.1 time",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "repeat", name: "repeat",
aliases: &[], aliases: &[],
category: "Sample", category: "Sample",
stack: "(n --)", stack: "(v.. --)",
desc: "Set repeat count", desc: "Set repeat count",
example: "4 repeat", example: "4 repeat",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "dur", name: "dur",
aliases: &[], aliases: &[],
category: "Sample", category: "Sample",
stack: "(f --)", stack: "(v.. --)",
desc: "Set duration", desc: "Set duration",
example: "0.5 dur", example: "0.5 dur",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "gate", name: "gate",
aliases: &[], aliases: &[],
category: "Sample", category: "Sample",
stack: "(f --)", stack: "(v.. --)",
desc: "Set gate time", desc: "Set gate time",
example: "0.8 gate", example: "0.8 gate",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "speed", name: "speed",
aliases: &[], aliases: &[],
category: "Sample", category: "Sample",
stack: "(f --)", stack: "(v.. --)",
desc: "Set playback speed", desc: "Set playback speed",
example: "1.5 speed", example: "1.5 speed",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "begin", name: "begin",
aliases: &[], aliases: &[],
category: "Sample", category: "Sample",
stack: "(f --)", stack: "(v.. --)",
desc: "Set sample start (0-1)", desc: "Set sample start (0-1)",
example: "0.25 begin", example: "0.25 begin",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "end", name: "end",
aliases: &[], aliases: &[],
category: "Sample", category: "Sample",
stack: "(f --)", stack: "(v.. --)",
desc: "Set sample end (0-1)", desc: "Set sample end (0-1)",
example: "0.75 end", example: "0.75 end",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "voice", name: "voice",
aliases: &[], aliases: &[],
category: "Sample", category: "Sample",
stack: "(n --)", stack: "(v.. --)",
desc: "Set voice number", desc: "Set voice number",
example: "1 voice", example: "1 voice",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "orbit", name: "orbit",
aliases: &[], aliases: &[],
category: "Sample", category: "Sample",
stack: "(n --)", stack: "(v.. --)",
desc: "Set orbit/bus", desc: "Set orbit/bus",
example: "0 orbit", example: "0 orbit",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "n", name: "n",
aliases: &[], aliases: &[],
category: "Sample", category: "Sample",
stack: "(n --)", stack: "(v.. --)",
desc: "Set sample number", desc: "Set sample number",
example: "0 n", example: "0 n",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "cut", name: "cut",
aliases: &[], aliases: &[],
category: "Sample", category: "Sample",
stack: "(n --)", stack: "(v.. --)",
desc: "Set cut group", desc: "Set cut group",
example: "1 cut", example: "1 cut",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "reset", name: "reset",
aliases: &[], aliases: &[],
category: "Sample", category: "Sample",
stack: "(n --)", stack: "(v.. --)",
desc: "Reset parameter", desc: "Reset parameter",
example: "1 reset", example: "1 reset",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
// Oscillator // Oscillator
Word { Word {
name: "freq", name: "freq",
aliases: &[], aliases: &[],
category: "Oscillator", category: "Oscillator",
stack: "(f --)", stack: "(v.. --)",
desc: "Set frequency (Hz)", desc: "Set frequency (Hz)",
example: "440 freq", example: "440 freq",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "detune", name: "detune",
aliases: &[], aliases: &[],
category: "Oscillator", category: "Oscillator",
stack: "(f --)", stack: "(v.. --)",
desc: "Set detune amount", desc: "Set detune amount",
example: "0.01 detune", example: "0.01 detune",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "glide", name: "glide",
aliases: &[], aliases: &[],
category: "Oscillator", category: "Oscillator",
stack: "(f --)", stack: "(v.. --)",
desc: "Set glide/portamento", desc: "Set glide/portamento",
example: "0.1 glide", example: "0.1 glide",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "pw", name: "pw",
aliases: &[], aliases: &[],
category: "Oscillator", category: "Oscillator",
stack: "(f --)", stack: "(v.. --)",
desc: "Set pulse width", desc: "Set pulse width",
example: "0.5 pw", example: "0.5 pw",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "spread", name: "spread",
aliases: &[], aliases: &[],
category: "Oscillator", category: "Oscillator",
stack: "(f --)", stack: "(v.. --)",
desc: "Set stereo spread", desc: "Set stereo spread",
example: "0.5 spread", example: "0.5 spread",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "mult", name: "mult",
aliases: &[], aliases: &[],
category: "Oscillator", category: "Oscillator",
stack: "(f --)", stack: "(v.. --)",
desc: "Set multiplier", desc: "Set multiplier",
example: "2 mult", example: "2 mult",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "warp", name: "warp",
aliases: &[], aliases: &[],
category: "Oscillator", category: "Oscillator",
stack: "(f --)", stack: "(v.. --)",
desc: "Set warp amount", desc: "Set warp amount",
example: "0.5 warp", example: "0.5 warp",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "mirror", name: "mirror",
aliases: &[], aliases: &[],
category: "Oscillator", category: "Oscillator",
stack: "(f --)", stack: "(v.. --)",
desc: "Set mirror", desc: "Set mirror",
example: "1 mirror", example: "1 mirror",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "harmonics", name: "harmonics",
aliases: &[], aliases: &[],
category: "Oscillator", category: "Oscillator",
stack: "(f --)", stack: "(v.. --)",
desc: "Set harmonics (mutable only)", desc: "Set harmonics (mutable only)",
example: "4 harmonics", example: "4 harmonics",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "timbre", name: "timbre",
aliases: &[], aliases: &[],
category: "Oscillator", category: "Oscillator",
stack: "(f --)", stack: "(v.. --)",
desc: "Set timbre (mutable only)", desc: "Set timbre (mutable only)",
example: "0.5 timbre", example: "0.5 timbre",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "morph", name: "morph",
aliases: &[], aliases: &[],
category: "Oscillator", category: "Oscillator",
stack: "(f --)", stack: "(v.. --)",
desc: "Set morph (mutable only)", desc: "Set morph (mutable only)",
example: "0.5 morph", example: "0.5 morph",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "coarse", name: "coarse",
aliases: &[], aliases: &[],
category: "Oscillator", category: "Oscillator",
stack: "(f --)", stack: "(v.. --)",
desc: "Set coarse tune", desc: "Set coarse tune",
example: "12 coarse", example: "12 coarse",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "sub", name: "sub",
aliases: &[], aliases: &[],
category: "Oscillator", category: "Oscillator",
stack: "(f --)", stack: "(v.. --)",
desc: "Set sub oscillator level", desc: "Set sub oscillator level",
example: "0.5 sub", example: "0.5 sub",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "suboct", name: "suboct",
aliases: &[], aliases: &[],
category: "Oscillator", category: "Oscillator",
stack: "(n --)", stack: "(v.. --)",
desc: "Set sub oscillator octave", desc: "Set sub oscillator octave",
example: "2 suboct", example: "2 suboct",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "subwave", name: "subwave",
aliases: &[], aliases: &[],
category: "Oscillator", category: "Oscillator",
stack: "(n --)", stack: "(v.. --)",
desc: "Set sub oscillator waveform", desc: "Set sub oscillator waveform",
example: "1 subwave", example: "1 subwave",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "note", name: "note",
aliases: &[], aliases: &[],
category: "Oscillator", category: "Oscillator",
stack: "(n --)", stack: "(v.. --)",
desc: "Set MIDI note", desc: "Set MIDI note",
example: "60 note", example: "60 note",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
// Wavetable // Wavetable
Word { Word {
name: "scan", name: "scan",
aliases: &[], aliases: &[],
category: "Wavetable", category: "Wavetable",
stack: "(f --)", stack: "(v.. --)",
desc: "Set wavetable scan position (0-1)", desc: "Set wavetable scan position (0-1)",
example: "0.5 scan", example: "0.5 scan",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "wtlen", name: "wtlen",
aliases: &[], aliases: &[],
category: "Wavetable", category: "Wavetable",
stack: "(n --)", stack: "(v.. --)",
desc: "Set wavetable cycle length in samples", desc: "Set wavetable cycle length in samples",
example: "2048 wtlen", example: "2048 wtlen",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "scanlfo", name: "scanlfo",
aliases: &[], aliases: &[],
category: "Wavetable", category: "Wavetable",
stack: "(f --)", stack: "(v.. --)",
desc: "Set scan LFO rate (Hz)", desc: "Set scan LFO rate (Hz)",
example: "0.2 scanlfo", example: "0.2 scanlfo",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "scandepth", name: "scandepth",
aliases: &[], aliases: &[],
category: "Wavetable", category: "Wavetable",
stack: "(f --)", stack: "(v.. --)",
desc: "Set scan LFO depth (0-1)", desc: "Set scan LFO depth (0-1)",
example: "0.4 scandepth", example: "0.4 scandepth",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "scanshape", name: "scanshape",
aliases: &[], aliases: &[],
category: "Wavetable", category: "Wavetable",
stack: "(s --)", stack: "(v.. --)",
desc: "Set scan LFO shape (sine/tri/saw/square/sh)", desc: "Set scan LFO shape (sine/tri/saw/square/sh)",
example: "\"tri\" scanshape", example: "\"tri\" scanshape",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
// FM // FM
Word { Word {
name: "fm", name: "fm",
aliases: &[], aliases: &[],
category: "FM", category: "FM",
stack: "(f --)", stack: "(v.. --)",
desc: "Set FM frequency", desc: "Set FM frequency",
example: "200 fm", example: "200 fm",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "fmh", name: "fmh",
aliases: &[], aliases: &[],
category: "FM", category: "FM",
stack: "(f --)", stack: "(v.. --)",
desc: "Set FM harmonic ratio", desc: "Set FM harmonic ratio",
example: "2 fmh", example: "2 fmh",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "fmshape", name: "fmshape",
aliases: &[], aliases: &[],
category: "FM", category: "FM",
stack: "(f --)", stack: "(v.. --)",
desc: "Set FM shape", desc: "Set FM shape",
example: "0 fmshape", example: "0 fmshape",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "fme", name: "fme",
aliases: &[], aliases: &[],
category: "FM", category: "FM",
stack: "(f --)", stack: "(v.. --)",
desc: "Set FM envelope", desc: "Set FM envelope",
example: "0.5 fme", example: "0.5 fme",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "fma", name: "fma",
aliases: &[], aliases: &[],
category: "FM", category: "FM",
stack: "(f --)", stack: "(v.. --)",
desc: "Set FM attack", desc: "Set FM attack",
example: "0.01 fma", example: "0.01 fma",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "fmd", name: "fmd",
aliases: &[], aliases: &[],
category: "FM", category: "FM",
stack: "(f --)", stack: "(v.. --)",
desc: "Set FM decay", desc: "Set FM decay",
example: "0.1 fmd", example: "0.1 fmd",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "fms", name: "fms",
aliases: &[], aliases: &[],
category: "FM", category: "FM",
stack: "(f --)", stack: "(v.. --)",
desc: "Set FM sustain", desc: "Set FM sustain",
example: "0.5 fms", example: "0.5 fms",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "fmr", name: "fmr",
aliases: &[], aliases: &[],
category: "FM", category: "FM",
stack: "(f --)", stack: "(v.. --)",
desc: "Set FM release", desc: "Set FM release",
example: "0.1 fmr", example: "0.1 fmr",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "fm2", name: "fm2",
aliases: &[], aliases: &[],
category: "FM", category: "FM",
stack: "(f --)", stack: "(v.. --)",
desc: "Set FM operator 2 depth", desc: "Set FM operator 2 depth",
example: "1.5 fm2", example: "1.5 fm2",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "fm2h", name: "fm2h",
aliases: &[], aliases: &[],
category: "FM", category: "FM",
stack: "(f --)", stack: "(v.. --)",
desc: "Set FM operator 2 harmonic ratio", desc: "Set FM operator 2 harmonic ratio",
example: "3 fm2h", example: "3 fm2h",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "fmalgo", name: "fmalgo",
aliases: &[], aliases: &[],
category: "FM", category: "FM",
stack: "(n --)", stack: "(v.. --)",
desc: "Set FM algorithm (0=cascade 1=parallel 2=branch)", desc: "Set FM algorithm (0=cascade 1=parallel 2=branch)",
example: "0 fmalgo", example: "0 fmalgo",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "fmfb", name: "fmfb",
aliases: &[], aliases: &[],
category: "FM", category: "FM",
stack: "(f --)", stack: "(v.. --)",
desc: "Set FM feedback amount", desc: "Set FM feedback amount",
example: "0.5 fmfb", example: "0.5 fmfb",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
// Modulation // Modulation
Word { Word {
name: "vib", name: "vib",
aliases: &[], aliases: &[],
category: "Modulation", category: "Modulation",
stack: "(f --)", stack: "(v.. --)",
desc: "Set vibrato rate", desc: "Set vibrato rate",
example: "5 vib", example: "5 vib",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "vibmod", name: "vibmod",
aliases: &[], aliases: &[],
category: "Modulation", category: "Modulation",
stack: "(f --)", stack: "(v.. --)",
desc: "Set vibrato depth", desc: "Set vibrato depth",
example: "0.5 vibmod", example: "0.5 vibmod",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "vibshape", name: "vibshape",
aliases: &[], aliases: &[],
category: "Modulation", category: "Modulation",
stack: "(f --)", stack: "(v.. --)",
desc: "Set vibrato shape", desc: "Set vibrato shape",
example: "0 vibshape", example: "0 vibshape",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "am", name: "am",
aliases: &[], aliases: &[],
category: "Modulation", category: "Modulation",
stack: "(f --)", stack: "(v.. --)",
desc: "Set AM frequency", desc: "Set AM frequency",
example: "10 am", example: "10 am",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "amdepth", name: "amdepth",
aliases: &[], aliases: &[],
category: "Modulation", category: "Modulation",
stack: "(f --)", stack: "(v.. --)",
desc: "Set AM depth", desc: "Set AM depth",
example: "0.5 amdepth", example: "0.5 amdepth",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "amshape", name: "amshape",
aliases: &[], aliases: &[],
category: "Modulation", category: "Modulation",
stack: "(f --)", stack: "(v.. --)",
desc: "Set AM shape", desc: "Set AM shape",
example: "0 amshape", example: "0 amshape",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "rm", name: "rm",
aliases: &[], aliases: &[],
category: "Modulation", category: "Modulation",
stack: "(f --)", stack: "(v.. --)",
desc: "Set RM frequency", desc: "Set RM frequency",
example: "100 rm", example: "100 rm",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "rmdepth", name: "rmdepth",
aliases: &[], aliases: &[],
category: "Modulation", category: "Modulation",
stack: "(f --)", stack: "(v.. --)",
desc: "Set RM depth", desc: "Set RM depth",
example: "0.5 rmdepth", example: "0.5 rmdepth",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
Word { Word {
name: "rmshape", name: "rmshape",
aliases: &[], aliases: &[],
category: "Modulation", category: "Modulation",
stack: "(f --)", stack: "(v.. --)",
desc: "Set RM shape", desc: "Set RM shape",
example: "0 rmshape", example: "0 rmshape",
compile: Param, compile: Param,
varargs: false, varargs: true,
}, },
// LFO // LFO
Word { Word {

View File

@@ -0,0 +1,145 @@
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, List, ListItem};
use ratatui::Frame;
use crate::theme;
pub struct CategoryItem<'a> {
pub label: &'a str,
pub is_section: bool,
}
pub struct CategoryList<'a> {
items: &'a [CategoryItem<'a>],
selected: usize,
focused: bool,
title: &'a str,
section_color: Color,
focused_color: Color,
selected_color: Color,
normal_color: Color,
dimmed_color: Option<Color>,
}
impl<'a> CategoryList<'a> {
pub fn new(items: &'a [CategoryItem<'a>], selected: usize) -> Self {
let theme = theme::get();
Self {
items,
selected,
focused: false,
title: "",
section_color: theme.ui.text_dim,
focused_color: theme.dict.category_focused,
selected_color: theme.dict.category_selected,
normal_color: theme.dict.category_normal,
dimmed_color: None,
}
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn title(mut self, title: &'a str) -> Self {
self.title = title;
self
}
pub fn selected_color(mut self, color: Color) -> Self {
self.selected_color = color;
self
}
pub fn normal_color(mut self, color: Color) -> Self {
self.normal_color = color;
self
}
pub fn dimmed(mut self, color: Color) -> Self {
self.dimmed_color = Some(color);
self
}
pub fn render(self, frame: &mut Frame, area: Rect) {
let theme = theme::get();
let visible_height = area.height.saturating_sub(2) as usize;
let total_items = self.items.len();
let selected_visual_idx = {
let mut visual = 0;
let mut selectable_count = 0;
for item in self.items.iter() {
if !item.is_section {
if selectable_count == self.selected {
break;
}
selectable_count += 1;
}
visual += 1;
}
visual
};
let scroll = if selected_visual_idx < visible_height / 2 {
0
} else if selected_visual_idx > total_items.saturating_sub(visible_height / 2) {
total_items.saturating_sub(visible_height)
} else {
selected_visual_idx.saturating_sub(visible_height / 2)
};
let mut selectable_idx = self.items
.iter()
.take(scroll)
.filter(|e| !e.is_section)
.count();
let is_dimmed = self.dimmed_color.is_some();
let items: Vec<ListItem> = self.items
.iter()
.skip(scroll)
.take(visible_height)
.map(|item| {
if item.is_section {
let style = Style::new().fg(self.section_color);
ListItem::new(format!("{}", item.label)).style(style)
} else {
let is_selected = selectable_idx == self.selected;
let style = if let Some(dim_color) = self.dimmed_color {
Style::new().fg(dim_color)
} else if is_selected && self.focused {
Style::new()
.fg(self.focused_color)
.add_modifier(Modifier::BOLD)
} else if is_selected {
Style::new()
.fg(self.selected_color)
.add_modifier(Modifier::BOLD)
} else {
Style::new().fg(self.normal_color)
};
let prefix = if is_selected && !is_dimmed { "> " } else { " " };
selectable_idx += 1;
ListItem::new(format!("{prefix}{}", item.label)).style(style)
}
})
.collect();
let border_color = if self.focused {
theme.dict.border_focused
} else {
theme.dict.border_normal
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(border_color))
.title(self.title);
let list = List::new(items).block(block);
frame.render_widget(list, area);
}
}

View File

@@ -0,0 +1,27 @@
use ratatui::text::{Line, Span};
use ratatui::style::Style;
use crate::theme;
pub fn hint_line(pairs: &[(&str, &str)]) -> Line<'static> {
let theme = theme::get();
let key_style = Style::default().fg(theme.hint.key);
let text_style = Style::default().fg(theme.hint.text);
let spans: Vec<Span> = pairs
.iter()
.enumerate()
.flat_map(|(i, (key, action))| {
let mut s = vec![
Span::styled(key.to_string(), key_style),
Span::styled(format!(" {action}"), text_style),
];
if i + 1 < pairs.len() {
s.push(Span::styled(" ", text_style));
}
s
})
.collect();
Line::from(spans)
}

View File

@@ -1,12 +1,18 @@
mod active_patterns; mod active_patterns;
mod category_list;
mod confirm; mod confirm;
mod editor; mod editor;
mod file_browser; mod file_browser;
mod hint_bar;
mod list_select; mod list_select;
mod modal; mod modal;
mod nav_minimap; mod nav_minimap;
mod props_form;
mod sample_browser; mod sample_browser;
mod scope; mod scope;
mod scroll_indicators;
mod search_bar;
mod section_header;
mod sparkles; mod sparkles;
mod spectrum; mod spectrum;
mod text_input; mod text_input;
@@ -15,14 +21,20 @@ mod vu_meter;
mod waveform; mod waveform;
pub use active_patterns::{ActivePatterns, MuteStatus}; pub use active_patterns::{ActivePatterns, MuteStatus};
pub use category_list::{CategoryItem, CategoryList};
pub use confirm::ConfirmModal; pub use confirm::ConfirmModal;
pub use editor::{fuzzy_match, CompletionCandidate, Editor}; pub use editor::{fuzzy_match, CompletionCandidate, Editor};
pub use file_browser::FileBrowserModal; pub use file_browser::FileBrowserModal;
pub use hint_bar::hint_line;
pub use list_select::ListSelect; pub use list_select::ListSelect;
pub use modal::ModalFrame; pub use modal::ModalFrame;
pub use nav_minimap::{NavMinimap, NavTile}; pub use nav_minimap::{NavMinimap, NavTile};
pub use props_form::render_props_form;
pub use sample_browser::{SampleBrowser, TreeLine, TreeLineKind}; pub use sample_browser::{SampleBrowser, TreeLine, TreeLineKind};
pub use scope::{Orientation, Scope}; pub use scope::{Orientation, Scope};
pub use scroll_indicators::{render_scroll_indicators, IndicatorAlign};
pub use search_bar::render_search_bar;
pub use section_header::render_section_header;
pub use sparkles::Sparkles; pub use sparkles::Sparkles;
pub use spectrum::Spectrum; pub use spectrum::Spectrum;
pub use text_input::TextInputModal; pub use text_input::TextInputModal;

View File

@@ -0,0 +1,42 @@
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::theme;
pub fn render_props_form(frame: &mut Frame, area: Rect, fields: &[(&str, &str, bool)]) {
let theme = theme::get();
for (i, (label, value, selected)) in fields.iter().enumerate() {
let y = area.y + i as u16;
if y >= area.y + area.height {
break;
}
let (label_style, value_style) = if *selected {
(
Style::default()
.fg(theme.hint.key)
.add_modifier(Modifier::BOLD),
Style::default()
.fg(theme.ui.text_primary)
.bg(theme.ui.surface),
)
} else {
(
Style::default().fg(theme.ui.text_muted),
Style::default().fg(theme.ui.text_primary),
)
};
let label_area = Rect::new(area.x + 1, y, 14, 1);
let value_area = Rect::new(area.x + 16, y, area.width.saturating_sub(18), 1);
frame.render_widget(
Paragraph::new(format!("{label}:")).style(label_style),
label_area,
);
frame.render_widget(Paragraph::new(*value).style(value_style), value_area);
}
}

View File

@@ -0,0 +1,53 @@
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
pub enum IndicatorAlign {
Center,
Right,
}
pub fn render_scroll_indicators(
frame: &mut Frame,
area: Rect,
offset: usize,
visible: usize,
total: usize,
color: Color,
align: IndicatorAlign,
) {
let style = Style::new().fg(color);
match align {
IndicatorAlign::Center => {
if offset > 0 {
let indicator = Paragraph::new("")
.style(style)
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(indicator, Rect { height: 1, ..area });
}
if offset + visible < total {
let y = area.y + area.height.saturating_sub(1);
let indicator = Paragraph::new("")
.style(style)
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(indicator, Rect { y, height: 1, ..area });
}
}
IndicatorAlign::Right => {
let x = area.x + area.width.saturating_sub(1);
if offset > 0 {
let indicator = Paragraph::new("").style(style);
frame.render_widget(indicator, Rect::new(x, area.y, 1, 1));
}
if offset + visible < total {
let indicator = Paragraph::new("").style(style);
frame.render_widget(
indicator,
Rect::new(x, area.y + area.height.saturating_sub(1), 1, 1),
);
}
}
}
}

View File

@@ -0,0 +1,20 @@
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::theme;
pub fn render_search_bar(frame: &mut Frame, area: Rect, query: &str, active: bool) {
let theme = theme::get();
let style = if active {
Style::new().fg(theme.search.active)
} else {
Style::new().fg(theme.search.inactive)
};
let cursor = if active { "_" } else { "" };
let text = format!(" /{query}{cursor}");
let line = Line::from(Span::styled(text, style));
frame.render_widget(Paragraph::new(vec![line]), area);
}

View File

@@ -0,0 +1,30 @@
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::theme;
pub fn render_section_header(frame: &mut Frame, title: &str, focused: bool, area: Rect) {
let theme = theme::get();
let [header_area, divider_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
let header_style = if focused {
Style::new()
.fg(theme.engine.header_focused)
.add_modifier(Modifier::BOLD)
} else {
Style::new()
.fg(theme.engine.header)
.add_modifier(Modifier::BOLD)
};
frame.render_widget(Paragraph::new(title).style(header_style), header_area);
let divider = "".repeat(area.width as usize);
frame.render_widget(
Paragraph::new(divider).style(Style::new().fg(theme.engine.divider)),
divider_area,
);
}

View File

@@ -24,19 +24,23 @@ impl Widget for Spectrum<'_> {
let colors = theme::get(); let colors = theme::get();
let height = area.height as f32; let height = area.height as f32;
let band_width = area.width as usize / 32; let base = area.width as usize / 32;
if band_width == 0 { let remainder = area.width as usize % 32;
if base == 0 && remainder == 0 {
return; return;
} }
let mut x_start = area.x;
for (band, &mag) in self.data.iter().enumerate() { for (band, &mag) in self.data.iter().enumerate() {
let w = base + if band < remainder { 1 } else { 0 };
if w == 0 {
continue;
}
let bar_height = mag * height; let bar_height = mag * height;
let full_cells = bar_height as usize; let full_cells = bar_height as usize;
let frac = bar_height - full_cells as f32; let frac = bar_height - full_cells as f32;
let frac_idx = (frac * 8.0) as usize; let frac_idx = (frac * 8.0) as usize;
let x_start = area.x + (band * band_width) as u16;
for row in 0..area.height as usize { for row in 0..area.height as usize {
let y = area.y + area.height - 1 - row as u16; let y = area.y + area.height - 1 - row as u16;
let ratio = row as f32 / area.height as f32; let ratio = row as f32 / area.height as f32;
@@ -47,11 +51,8 @@ impl Widget for Spectrum<'_> {
} else { } else {
Color::Rgb(colors.meter.high_rgb.0, colors.meter.high_rgb.1, colors.meter.high_rgb.2) Color::Rgb(colors.meter.high_rgb.0, colors.meter.high_rgb.1, colors.meter.high_rgb.2)
}; };
for dx in 0..band_width as u16 { for dx in 0..w as u16 {
let x = x_start + dx; let x = x_start + dx;
if x >= area.x + area.width {
break;
}
if row < full_cells { if row < full_cells {
buf[(x, y)].set_char(BLOCKS[7]).set_fg(color); buf[(x, y)].set_char(BLOCKS[7]).set_fg(color);
} else if row == full_cells && frac_idx > 0 { } else if row == full_cells && frac_idx > 0 {
@@ -59,6 +60,7 @@ impl Widget for Spectrum<'_> {
} }
} }
} }
x_start += w as u16;
} }
} }
} }

View File

@@ -1183,6 +1183,7 @@ impl App {
AppCommand::PageRight => self.page.right(), AppCommand::PageRight => self.page.right(),
AppCommand::PageUp => self.page.up(), AppCommand::PageUp => self.page.up(),
AppCommand::PageDown => self.page.down(), AppCommand::PageDown => self.page.down(),
AppCommand::GoToPage(page) => self.page = page,
// Help navigation // Help navigation
AppCommand::HelpToggleFocus => help_nav::toggle_focus(&mut self.ui), AppCommand::HelpToggleFocus => help_nav::toggle_focus(&mut self.ui),

View File

@@ -1,6 +1,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode}; use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
use crate::page::Page;
use crate::state::{ColorScheme, DeviceKind, Modal, PatternField, SettingKind}; use crate::state::{ColorScheme, DeviceKind, Modal, PatternField, SettingKind};
pub enum AppCommand { pub enum AppCommand {
@@ -142,6 +143,7 @@ pub enum AppCommand {
PageRight, PageRight,
PageUp, PageUp,
PageDown, PageDown,
GoToPage(Page),
// Help navigation // Help navigation
HelpToggleFocus, HelpToggleFocus,

View File

@@ -118,10 +118,10 @@ impl SpectrumAnalyzer {
0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE - 1) as f32).cos()) 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE - 1) as f32).cos())
}); });
let nyquist = sample_rate / 2.0;
let min_freq: f32 = 20.0; let min_freq: f32 = 20.0;
let max_freq: f32 = 16000.0;
let log_min = min_freq.ln(); let log_min = min_freq.ln();
let log_max = nyquist.ln(); let log_max = max_freq.ln();
let band_edges: [usize; NUM_BANDS + 1] = std::array::from_fn(|i| { let band_edges: [usize; NUM_BANDS + 1] = std::array::from_fn(|i| {
let freq = (log_min + (log_max - log_min) * i as f32 / NUM_BANDS as f32).exp(); let freq = (log_min + (log_max - log_min) * i as f32 / NUM_BANDS as f32).exp();
let bin = (freq * FFT_SIZE as f32 / sample_rate).round() as usize; let bin = (freq * FFT_SIZE as f32 / sample_rate).round() as usize;

File diff suppressed because it is too large Load Diff

189
src/input/engine_page.rs Normal file
View File

@@ -0,0 +1,189 @@
use crossterm::event::{KeyCode, KeyEvent};
use std::sync::atomic::Ordering;
use super::{InputContext, InputResult};
use crate::commands::AppCommand;
use crate::engine::{AudioCommand, SeqCommand};
use crate::state::{ConfirmAction, DeviceKind, EngineSection, Modal, SettingKind};
pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
match key.code {
KeyCode::Char('q') => {
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
action: ConfirmAction::Quit,
selected: false,
}));
}
KeyCode::Tab => ctx.dispatch(AppCommand::AudioNextSection),
KeyCode::BackTab => ctx.dispatch(AppCommand::AudioPrevSection),
KeyCode::Up => match ctx.app.audio.section {
EngineSection::Devices => match ctx.app.audio.device_kind {
DeviceKind::Output => ctx.dispatch(AppCommand::AudioOutputListUp),
DeviceKind::Input => ctx.dispatch(AppCommand::AudioInputListUp),
},
EngineSection::Settings => {
ctx.dispatch(AppCommand::AudioSettingPrev);
}
EngineSection::Samples => {}
},
KeyCode::Down => match ctx.app.audio.section {
EngineSection::Devices => match ctx.app.audio.device_kind {
DeviceKind::Output => {
let count = ctx.app.audio.output_devices.len();
ctx.dispatch(AppCommand::AudioOutputListDown(count));
}
DeviceKind::Input => {
let count = ctx.app.audio.input_devices.len();
ctx.dispatch(AppCommand::AudioInputListDown(count));
}
},
EngineSection::Settings => {
ctx.dispatch(AppCommand::AudioSettingNext);
}
EngineSection::Samples => {}
},
KeyCode::PageUp => {
if ctx.app.audio.section == EngineSection::Devices {
match ctx.app.audio.device_kind {
DeviceKind::Output => ctx.dispatch(AppCommand::AudioOutputPageUp),
DeviceKind::Input => ctx.app.audio.input_list.page_up(),
}
}
}
KeyCode::PageDown => {
if ctx.app.audio.section == EngineSection::Devices {
match ctx.app.audio.device_kind {
DeviceKind::Output => {
let count = ctx.app.audio.output_devices.len();
ctx.dispatch(AppCommand::AudioOutputPageDown(count));
}
DeviceKind::Input => {
let count = ctx.app.audio.input_devices.len();
ctx.dispatch(AppCommand::AudioInputPageDown(count));
}
}
}
}
KeyCode::Enter => {
if ctx.app.audio.section == EngineSection::Devices {
match ctx.app.audio.device_kind {
DeviceKind::Output => {
let cursor = ctx.app.audio.output_list.cursor;
if cursor < ctx.app.audio.output_devices.len() {
let name = ctx.app.audio.output_devices[cursor].name.clone();
ctx.dispatch(AppCommand::SetOutputDevice(name));
ctx.app.save_settings(ctx.link);
}
}
DeviceKind::Input => {
let cursor = ctx.app.audio.input_list.cursor;
if cursor < ctx.app.audio.input_devices.len() {
let name = ctx.app.audio.input_devices[cursor].name.clone();
ctx.dispatch(AppCommand::SetInputDevice(name));
ctx.app.save_settings(ctx.link);
}
}
}
}
}
KeyCode::Left => match ctx.app.audio.section {
EngineSection::Devices => {
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Output));
}
EngineSection::Settings => {
match ctx.app.audio.setting_kind {
SettingKind::Channels => ctx.dispatch(AppCommand::AdjustAudioSetting {
setting: SettingKind::Channels,
delta: -1,
}),
SettingKind::BufferSize => ctx.dispatch(AppCommand::AdjustAudioSetting {
setting: SettingKind::BufferSize,
delta: -64,
}),
SettingKind::Polyphony => ctx.dispatch(AppCommand::AdjustAudioSetting {
setting: SettingKind::Polyphony,
delta: -1,
}),
SettingKind::Nudge => {
let prev = ctx.nudge_us.load(Ordering::Relaxed);
ctx.nudge_us
.store((prev - 1000).max(-100_000), Ordering::Relaxed);
}
}
ctx.app.save_settings(ctx.link);
}
EngineSection::Samples => {}
},
KeyCode::Right => match ctx.app.audio.section {
EngineSection::Devices => {
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Input));
}
EngineSection::Settings => {
match ctx.app.audio.setting_kind {
SettingKind::Channels => ctx.dispatch(AppCommand::AdjustAudioSetting {
setting: SettingKind::Channels,
delta: 1,
}),
SettingKind::BufferSize => ctx.dispatch(AppCommand::AdjustAudioSetting {
setting: SettingKind::BufferSize,
delta: 64,
}),
SettingKind::Polyphony => ctx.dispatch(AppCommand::AdjustAudioSetting {
setting: SettingKind::Polyphony,
delta: 1,
}),
SettingKind::Nudge => {
let prev = ctx.nudge_us.load(Ordering::Relaxed);
ctx.nudge_us
.store((prev + 1000).min(100_000), Ordering::Relaxed);
}
}
ctx.app.save_settings(ctx.link);
}
EngineSection::Samples => {}
},
KeyCode::Char('R') => ctx.dispatch(AppCommand::AudioTriggerRestart),
KeyCode::Char('A') => {
use crate::state::file_browser::FileBrowserState;
let state = FileBrowserState::new_load(String::new());
ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(Box::new(state))));
}
KeyCode::Char('D') => {
if ctx.app.audio.section == EngineSection::Samples {
ctx.dispatch(AppCommand::RemoveLastSamplePath);
} else {
ctx.dispatch(AppCommand::AudioRefreshDevices);
let out_count = ctx.app.audio.output_devices.len();
let in_count = ctx.app.audio.input_devices.len();
ctx.dispatch(AppCommand::SetStatus(format!(
"Found {out_count} output, {in_count} input devices"
)));
}
}
KeyCode::Char('h') => {
let _ = ctx.audio_tx.load().send(AudioCommand::Hush);
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
}
KeyCode::Char('p') => {
let _ = ctx.audio_tx.load().send(AudioCommand::Panic);
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
}
KeyCode::Char('r') => ctx.dispatch(AppCommand::ResetPeakVoices),
KeyCode::Char('t') => {
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate {
cmd: "/sound/sine/dur/0.5/decay/0.2".into(),
time: None,
});
}
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
KeyCode::Char(' ') => {
ctx.dispatch(AppCommand::TogglePlaying);
ctx.playing
.store(ctx.app.playback.playing, Ordering::Relaxed);
}
_ => {}
}
InputResult::Continue
}

114
src/input/help_page.rs Normal file
View File

@@ -0,0 +1,114 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::sync::atomic::Ordering;
use super::{InputContext, InputResult};
use crate::commands::AppCommand;
use crate::state::{ConfirmAction, DictFocus, HelpFocus, Modal};
pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctx.app.ui.help_search_active {
match key.code {
KeyCode::Esc => ctx.dispatch(AppCommand::HelpClearSearch),
KeyCode::Enter => ctx.dispatch(AppCommand::HelpSearchConfirm),
KeyCode::Backspace => ctx.dispatch(AppCommand::HelpSearchBackspace),
KeyCode::Char(c) if !ctrl => ctx.dispatch(AppCommand::HelpSearchInput(c)),
_ => {}
}
return InputResult::Continue;
}
match key.code {
KeyCode::Char('/') | KeyCode::Char('f') if key.code == KeyCode::Char('/') || ctrl => {
ctx.dispatch(AppCommand::HelpActivateSearch);
}
KeyCode::Esc if !ctx.app.ui.help_search_query.is_empty() => {
ctx.dispatch(AppCommand::HelpClearSearch);
}
KeyCode::Tab => ctx.dispatch(AppCommand::HelpToggleFocus),
KeyCode::Char('j') | KeyCode::Down if ctrl => {
ctx.dispatch(AppCommand::HelpNextTopic(5));
}
KeyCode::Char('k') | KeyCode::Up if ctrl => {
ctx.dispatch(AppCommand::HelpPrevTopic(5));
}
KeyCode::Char('j') | KeyCode::Down => match ctx.app.ui.help_focus {
HelpFocus::Topics => ctx.dispatch(AppCommand::HelpNextTopic(1)),
HelpFocus::Content => ctx.dispatch(AppCommand::HelpScrollDown(1)),
},
KeyCode::Char('k') | KeyCode::Up => match ctx.app.ui.help_focus {
HelpFocus::Topics => ctx.dispatch(AppCommand::HelpPrevTopic(1)),
HelpFocus::Content => ctx.dispatch(AppCommand::HelpScrollUp(1)),
},
KeyCode::PageDown => ctx.dispatch(AppCommand::HelpScrollDown(10)),
KeyCode::PageUp => ctx.dispatch(AppCommand::HelpScrollUp(10)),
KeyCode::Char('q') => {
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
action: ConfirmAction::Quit,
selected: false,
}));
}
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
KeyCode::Char(' ') => {
ctx.dispatch(AppCommand::TogglePlaying);
ctx.playing
.store(ctx.app.playback.playing, Ordering::Relaxed);
}
_ => {}
}
InputResult::Continue
}
pub(super) fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctx.app.ui.dict_search_active {
match key.code {
KeyCode::Esc => ctx.dispatch(AppCommand::DictClearSearch),
KeyCode::Enter => ctx.dispatch(AppCommand::DictSearchConfirm),
KeyCode::Backspace => ctx.dispatch(AppCommand::DictSearchBackspace),
KeyCode::Char(c) if !ctrl => ctx.dispatch(AppCommand::DictSearchInput(c)),
_ => {}
}
return InputResult::Continue;
}
match key.code {
KeyCode::Char('/') | KeyCode::Char('f') if key.code == KeyCode::Char('/') || ctrl => {
ctx.dispatch(AppCommand::DictActivateSearch);
}
KeyCode::Esc if !ctx.app.ui.dict_search_query.is_empty() => {
ctx.dispatch(AppCommand::DictClearSearch);
}
KeyCode::Tab => ctx.dispatch(AppCommand::DictToggleFocus),
KeyCode::Char('j') | KeyCode::Down => match ctx.app.ui.dict_focus {
DictFocus::Categories => ctx.dispatch(AppCommand::DictNextCategory),
DictFocus::Words => ctx.dispatch(AppCommand::DictScrollDown(1)),
},
KeyCode::Char('k') | KeyCode::Up => match ctx.app.ui.dict_focus {
DictFocus::Categories => ctx.dispatch(AppCommand::DictPrevCategory),
DictFocus::Words => ctx.dispatch(AppCommand::DictScrollUp(1)),
},
KeyCode::PageDown => ctx.dispatch(AppCommand::DictScrollDown(10)),
KeyCode::PageUp => ctx.dispatch(AppCommand::DictScrollUp(10)),
KeyCode::Char('q') => {
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
action: ConfirmAction::Quit,
selected: false,
}));
}
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
KeyCode::Char(' ') => {
ctx.dispatch(AppCommand::TogglePlaying);
ctx.playing
.store(ctx.app.playback.playing, Ordering::Relaxed);
}
_ => {}
}
InputResult::Continue
}

248
src/input/main_page.rs Normal file
View File

@@ -0,0 +1,248 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::sync::atomic::Ordering;
use super::{InputContext, InputResult};
use crate::commands::AppCommand;
use crate::state::{
ConfirmAction, CyclicEnum, EuclideanField, Modal, PanelFocus, PatternField, RenameTarget,
SampleBrowserState, SidePanel,
};
pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputResult {
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Tab => {
if ctx.app.panel.visible {
ctx.app.panel.visible = false;
ctx.app.panel.focus = PanelFocus::Main;
} else {
if ctx.app.panel.side.is_none() {
let state = SampleBrowserState::new(&ctx.app.audio.config.sample_paths);
ctx.app.panel.side = Some(SidePanel::SampleBrowser(state));
}
ctx.app.panel.visible = true;
ctx.app.panel.focus = PanelFocus::Side;
}
}
KeyCode::Char('q') => {
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
action: ConfirmAction::Quit,
selected: false,
}));
}
KeyCode::Char(' ') => {
ctx.dispatch(AppCommand::TogglePlaying);
ctx.playing
.store(ctx.app.playback.playing, Ordering::Relaxed);
}
KeyCode::Left if shift && !ctrl => {
if ctx.app.editor_ctx.selection_anchor.is_none() {
ctx.dispatch(AppCommand::SetSelectionAnchor(ctx.app.editor_ctx.step));
}
ctx.dispatch(AppCommand::PrevStep);
}
KeyCode::Right if shift && !ctrl => {
if ctx.app.editor_ctx.selection_anchor.is_none() {
ctx.dispatch(AppCommand::SetSelectionAnchor(ctx.app.editor_ctx.step));
}
ctx.dispatch(AppCommand::NextStep);
}
KeyCode::Up if shift && !ctrl => {
if ctx.app.editor_ctx.selection_anchor.is_none() {
ctx.dispatch(AppCommand::SetSelectionAnchor(ctx.app.editor_ctx.step));
}
ctx.dispatch(AppCommand::StepUp);
}
KeyCode::Down if shift && !ctrl => {
if ctx.app.editor_ctx.selection_anchor.is_none() {
ctx.dispatch(AppCommand::SetSelectionAnchor(ctx.app.editor_ctx.step));
}
ctx.dispatch(AppCommand::StepDown);
}
KeyCode::Left => {
ctx.app.editor_ctx.clear_selection();
ctx.dispatch(AppCommand::PrevStep);
}
KeyCode::Right => {
ctx.app.editor_ctx.clear_selection();
ctx.dispatch(AppCommand::NextStep);
}
KeyCode::Up => {
ctx.app.editor_ctx.clear_selection();
ctx.dispatch(AppCommand::StepUp);
}
KeyCode::Down => {
ctx.app.editor_ctx.clear_selection();
ctx.dispatch(AppCommand::StepDown);
}
KeyCode::Esc => {
ctx.app.editor_ctx.clear_selection();
}
KeyCode::Enter => {
ctx.app.editor_ctx.clear_selection();
ctx.dispatch(AppCommand::OpenModal(Modal::Editor));
}
KeyCode::Char('t') => ctx.dispatch(AppCommand::ToggleSteps),
KeyCode::Char('s') => {
use crate::state::file_browser::FileBrowserState;
let initial = ctx
.app
.project_state
.file_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
let state = FileBrowserState::new_save(initial);
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(Box::new(state))));
}
KeyCode::Char('c') if ctrl => {
ctx.dispatch(AppCommand::CopySteps);
}
KeyCode::Char('v') if ctrl => {
ctx.dispatch(AppCommand::PasteSteps);
}
KeyCode::Char('b') if ctrl => {
ctx.dispatch(AppCommand::LinkPasteSteps);
}
KeyCode::Char('d') if ctrl => {
ctx.dispatch(AppCommand::DuplicateSteps);
}
KeyCode::Char('h') if ctrl => ctx.dispatch(AppCommand::HardenSteps),
KeyCode::Char('l') => {
use crate::state::file_browser::FileBrowserState;
let default_dir = ctx
.app
.project_state
.file_path
.as_ref()
.and_then(|p| p.parent())
.map(|p| {
let mut s = p.display().to_string();
if !s.ends_with('/') {
s.push('/');
}
s
})
.unwrap_or_default();
let state = FileBrowserState::new_load(default_dir);
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(Box::new(state))));
}
KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp),
KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown),
KeyCode::Char('T') => {
let current = format!("{:.1}", ctx.link.tempo());
ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current)));
}
KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease),
KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease),
KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease),
KeyCode::Char(']') => ctx.dispatch(AppCommand::SpeedIncrease),
KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)),
KeyCode::Char('S') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Speed)),
KeyCode::Char('p') => ctx.dispatch(AppCommand::OpenModal(Modal::Preview)),
KeyCode::Delete | KeyCode::Backspace => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
if let Some(range) = ctx.app.editor_ctx.selection_range() {
let steps: Vec<usize> = range.collect();
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
action: ConfirmAction::DeleteSteps { bank, pattern, steps },
selected: false,
}));
} else {
let step = ctx.app.editor_ctx.step;
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
action: ConfirmAction::DeleteStep { bank, pattern, step },
selected: false,
}));
}
}
KeyCode::Char('r') if ctrl => {
let pattern = ctx.app.current_edit_pattern();
if let Some(script) = pattern.resolve_script(ctx.app.editor_ctx.step) {
if !script.trim().is_empty() {
match ctx
.app
.execute_script_oneshot(script, ctx.link, ctx.audio_tx)
{
Ok(()) => ctx
.app
.ui
.flash("Executed", 100, crate::state::FlashKind::Info),
Err(e) => ctx.app.ui.flash(
&format!("Error: {e}"),
200,
crate::state::FlashKind::Error,
),
}
}
}
}
KeyCode::Char('r') => {
let (bank, pattern, step) = (
ctx.app.editor_ctx.bank,
ctx.app.editor_ctx.pattern,
ctx.app.editor_ctx.step,
);
let current_name = ctx
.app
.current_edit_pattern()
.step(step)
.and_then(|s| s.name.clone())
.unwrap_or_default();
ctx.dispatch(AppCommand::OpenModal(Modal::Rename {
target: RenameTarget::Step { bank, pattern, step },
name: current_name,
}));
}
KeyCode::Char('o') => {
ctx.app.audio.config.layout = ctx.app.audio.config.layout.next();
}
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
KeyCode::Char('e') | KeyCode::Char('E') => {
let (bank, pattern, step) = (
ctx.app.editor_ctx.bank,
ctx.app.editor_ctx.pattern,
ctx.app.editor_ctx.step,
);
let pattern_len = ctx.app.current_edit_pattern().length;
let default_steps = pattern_len.min(32);
let default_pulses = (default_steps / 2).max(1).min(default_steps);
ctx.dispatch(AppCommand::OpenModal(Modal::EuclideanDistribution {
bank,
pattern,
source_step: step,
field: EuclideanField::Pulses,
pulses: default_pulses.to_string(),
steps: default_steps.to_string(),
rotation: "0".to_string(),
}));
}
KeyCode::Char('m') => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
ctx.dispatch(AppCommand::StageMute { bank, pattern });
}
KeyCode::Char('x') => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
ctx.dispatch(AppCommand::StageSolo { bank, pattern });
}
KeyCode::Char('M') => {
ctx.dispatch(AppCommand::ClearMutes);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
KeyCode::Char('X') => {
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
}

181
src/input/mod.rs Normal file
View File

@@ -0,0 +1,181 @@
mod engine_page;
mod help_page;
mod main_page;
mod modal;
mod options_page;
mod panel;
mod patterns_page;
use arc_swap::ArcSwap;
use crossbeam_channel::Sender;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use std::sync::atomic::{AtomicBool, AtomicI64};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::app::App;
use crate::commands::AppCommand;
use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot};
use crate::page::Page;
use crate::state::{Modal, PanelFocus};
pub enum InputResult {
Continue,
Quit,
}
pub struct InputContext<'a> {
pub app: &'a mut App,
pub link: &'a LinkState,
pub snapshot: &'a SequencerSnapshot,
pub playing: &'a Arc<AtomicBool>,
pub audio_tx: &'a ArcSwap<Sender<AudioCommand>>,
pub seq_cmd_tx: &'a Sender<SeqCommand>,
pub nudge_us: &'a Arc<AtomicI64>,
}
impl<'a> InputContext<'a> {
fn dispatch(&mut self, cmd: AppCommand) {
self.app.dispatch(cmd, self.link, self.snapshot);
}
}
pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
if handle_live_keys(ctx, &key) {
return InputResult::Continue;
}
if key.kind == KeyEventKind::Release {
return InputResult::Continue;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let is_arrow = matches!(
key.code,
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down
);
if ctx.app.ui.minimap_until.is_some() && !(ctrl && is_arrow) {
ctx.dispatch(AppCommand::ClearMinimap);
}
if ctx.app.ui.show_title {
ctx.dispatch(AppCommand::HideTitle);
return InputResult::Continue;
}
ctx.dispatch(AppCommand::ClearStatus);
if matches!(ctx.app.ui.modal, Modal::None) {
handle_normal_input(ctx, key)
} else {
modal::handle_modal_input(ctx, key)
}
}
fn handle_live_keys(ctx: &mut InputContext, key: &KeyEvent) -> bool {
match (key.code, key.kind) {
_ if !matches!(ctx.app.ui.modal, Modal::None) => false,
(KeyCode::Char('f'), KeyEventKind::Press) => {
ctx.dispatch(AppCommand::ToggleLiveKeysFill);
true
}
_ => false,
}
}
fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctx.app.panel.visible && ctx.app.panel.focus == PanelFocus::Side {
return panel::handle_panel_input(ctx, key);
}
if ctrl {
let minimap_timeout = Some(Instant::now() + Duration::from_millis(250));
match key.code {
KeyCode::Left => {
ctx.app.ui.minimap_until = minimap_timeout;
ctx.dispatch(AppCommand::PageLeft);
return InputResult::Continue;
}
KeyCode::Right => {
ctx.app.ui.minimap_until = minimap_timeout;
ctx.dispatch(AppCommand::PageRight);
return InputResult::Continue;
}
KeyCode::Up => {
ctx.app.ui.minimap_until = minimap_timeout;
ctx.dispatch(AppCommand::PageUp);
return InputResult::Continue;
}
KeyCode::Down => {
ctx.app.ui.minimap_until = minimap_timeout;
ctx.dispatch(AppCommand::PageDown);
return InputResult::Continue;
}
_ => {}
}
}
if let Some(page) = match key.code {
KeyCode::F(1) => Some(Page::Dict),
KeyCode::F(2) => Some(Page::Patterns),
KeyCode::F(3) => Some(Page::Options),
KeyCode::F(4) => Some(Page::Help),
KeyCode::F(5) => Some(Page::Main),
KeyCode::F(6) => Some(Page::Engine),
_ => None,
} {
ctx.app.ui.minimap_until = Some(Instant::now() + Duration::from_millis(250));
ctx.dispatch(AppCommand::GoToPage(page));
return InputResult::Continue;
}
match ctx.app.page {
Page::Main => main_page::handle_main_page(ctx, key, ctrl),
Page::Patterns => patterns_page::handle_patterns_page(ctx, key),
Page::Engine => engine_page::handle_engine_page(ctx, key),
Page::Options => options_page::handle_options_page(ctx, key),
Page::Help => help_page::handle_help_page(ctx, key),
Page::Dict => help_page::handle_dict_page(ctx, key),
}
}
fn load_project_samples(ctx: &mut InputContext) {
let paths = ctx.app.project_state.project.sample_paths.clone();
if paths.is_empty() {
return;
}
let mut total_count = 0;
let mut all_preload_entries = Vec::new();
for path in &paths {
if path.is_dir() {
let index = doux::sampling::scan_samples_dir(path);
let count = index.len();
total_count += count;
for e in &index {
all_preload_entries.push((e.name.clone(), e.path.clone()));
}
let _ = ctx.audio_tx.load().send(AudioCommand::LoadSamples(index));
}
}
ctx.app.audio.config.sample_paths = paths;
ctx.app.audio.config.sample_count = total_count;
if total_count > 0 {
if let Some(registry) = ctx.app.audio.sample_registry.clone() {
let sr = ctx.app.audio.config.sample_rate;
std::thread::Builder::new()
.name("sample-preload".into())
.spawn(move || {
crate::init::preload_sample_heads(all_preload_entries, sr, &registry);
})
.expect("failed to spawn preload thread");
}
ctx.dispatch(AppCommand::SetStatus(format!(
"Loaded {total_count} samples from project"
)));
}
}

553
src/input/modal.rs Normal file
View File

@@ -0,0 +1,553 @@
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::state::{
ConfirmAction, EditorTarget, EuclideanField, Modal, PatternField,
PatternPropsField, RenameTarget,
};
pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
match &mut ctx.app.ui.modal {
Modal::Confirm { action, selected } => {
let (action, confirmed) = (action.clone(), *selected);
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => return execute_confirm(ctx, &action),
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Left | KeyCode::Right => {
if let Modal::Confirm { selected, .. } = &mut ctx.app.ui.modal {
*selected = !*selected;
}
}
KeyCode::Enter => {
if confirmed {
return execute_confirm(ctx, &action);
}
ctx.dispatch(AppCommand::CloseModal);
}
_ => {}
}
}
Modal::FileBrowser(state) => match key.code {
KeyCode::Enter => {
use crate::state::file_browser::FileBrowserMode;
let mode = state.mode.clone();
if let Some(path) = state.confirm() {
ctx.dispatch(AppCommand::CloseModal);
match mode {
FileBrowserMode::Save => ctx.dispatch(AppCommand::Save(path)),
FileBrowserMode::Load => {
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
let _ = ctx.seq_cmd_tx.send(SeqCommand::ResetScriptState);
ctx.dispatch(AppCommand::Load(path));
super::load_project_samples(ctx);
}
}
}
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Tab => state.autocomplete(),
KeyCode::Left => state.go_up(),
KeyCode::Right => state.enter_selected(),
KeyCode::Up => state.select_prev(12),
KeyCode::Down => state.select_next(12),
KeyCode::Backspace => state.backspace(),
KeyCode::Char(c) => {
state.input.push(c);
state.refresh_entries();
}
_ => {}
},
Modal::Rename { target, name } => {
let target = target.clone();
match key.code {
KeyCode::Enter => {
let new_name = if name.trim().is_empty() {
None
} else {
Some(name.clone())
};
ctx.dispatch(rename_command(&target, new_name));
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Backspace => {
if let Modal::Rename { name, .. } = &mut ctx.app.ui.modal {
name.pop();
}
}
KeyCode::Char(c) => {
if let Modal::Rename { name, .. } = &mut ctx.app.ui.modal {
name.push(c);
}
}
_ => {}
}
}
Modal::SetPattern { field, input } => match key.code {
KeyCode::Enter => {
let field = *field;
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
match field {
PatternField::Length => {
if let Ok(len) = input.parse::<usize>() {
ctx.dispatch(AppCommand::SetLength {
bank,
pattern,
length: len,
});
let new_len = ctx
.app
.project_state
.project
.pattern_at(bank, pattern)
.length;
ctx.dispatch(AppCommand::SetStatus(format!("Length set to {new_len}")));
} else {
ctx.dispatch(AppCommand::SetStatus("Invalid length".to_string()));
}
}
PatternField::Speed => {
if let Some(speed) = PatternSpeed::from_label(input) {
ctx.dispatch(AppCommand::SetSpeed {
bank,
pattern,
speed,
});
ctx.dispatch(AppCommand::SetStatus(format!(
"Speed set to {}",
speed.label()
)));
} else {
ctx.dispatch(AppCommand::SetStatus(
"Invalid speed (try 1/3, 2/5, 1x, 2x)".to_string(),
));
}
}
}
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Backspace => {
input.pop();
}
KeyCode::Char(c) => input.push(c),
_ => {}
},
Modal::SetTempo(input) => match key.code {
KeyCode::Enter => {
if let Ok(tempo) = input.parse::<f64>() {
let tempo = tempo.clamp(20.0, 300.0);
ctx.link.set_tempo(tempo);
ctx.dispatch(AppCommand::SetStatus(format!(
"Tempo set to {tempo:.1} BPM"
)));
} else {
ctx.dispatch(AppCommand::SetStatus("Invalid tempo".to_string()));
}
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Backspace => {
input.pop();
}
KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => input.push(c),
_ => {}
},
Modal::AddSamplePath(state) => match key.code {
KeyCode::Enter => {
let sample_path = if let Some(entry) = state.entries.get(state.selected) {
if entry.is_dir && entry.name != ".." {
Some(state.current_dir().join(&entry.name))
} else if entry.is_dir {
state.enter_selected();
None
} else {
None
}
} else {
let dir = state.current_dir();
if dir.is_dir() {
Some(dir)
} else {
None
}
};
if let Some(path) = sample_path {
let index = doux::sampling::scan_samples_dir(&path);
let count = index.len();
let preload_entries: Vec<(String, std::path::PathBuf)> = index
.iter()
.map(|e| (e.name.clone(), e.path.clone()))
.collect();
let _ = ctx.audio_tx.load().send(crate::engine::AudioCommand::LoadSamples(index));
ctx.app.audio.config.sample_count += count;
ctx.app.audio.add_sample_path(path);
if let Some(registry) = ctx.app.audio.sample_registry.clone() {
let sr = ctx.app.audio.config.sample_rate;
std::thread::Builder::new()
.name("sample-preload".into())
.spawn(move || {
crate::init::preload_sample_heads(preload_entries, sr, &registry);
})
.expect("failed to spawn preload thread");
}
ctx.dispatch(AppCommand::SetStatus(format!("Added {count} samples")));
ctx.dispatch(AppCommand::CloseModal);
}
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Tab => state.autocomplete(),
KeyCode::Left => state.go_up(),
KeyCode::Right => state.enter_selected(),
KeyCode::Up => state.select_prev(14),
KeyCode::Down => state.select_next(14),
KeyCode::Backspace => state.backspace(),
KeyCode::Char(c) => {
state.input.push(c);
state.refresh_entries();
}
_ => {}
},
Modal::Editor => {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let editor = &mut ctx.app.editor_ctx.editor;
if editor.search_active() {
match key.code {
KeyCode::Esc => editor.search_clear(),
KeyCode::Enter => editor.search_confirm(),
KeyCode::Backspace => editor.search_backspace(),
KeyCode::Char(c) if !ctrl => editor.search_input(c),
_ => {}
}
return InputResult::Continue;
}
if editor.sample_finder_active() {
match key.code {
KeyCode::Esc => editor.dismiss_sample_finder(),
KeyCode::Tab | KeyCode::Enter => editor.accept_sample_finder(),
KeyCode::Backspace => editor.sample_finder_backspace(),
KeyCode::Char('n') if ctrl => editor.sample_finder_next(),
KeyCode::Char('p') if ctrl => editor.sample_finder_prev(),
KeyCode::Char(c) if !ctrl => editor.sample_finder_input(c),
_ => {}
}
return InputResult::Continue;
}
match key.code {
KeyCode::Esc => {
if editor.is_selecting() {
editor.cancel_selection();
} 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('b') if ctrl => {
editor.activate_sample_finder();
}
KeyCode::Char('f') if ctrl => {
editor.activate_search();
}
KeyCode::Char('n') if ctrl => {
if editor.completion_active() {
editor.completion_next();
} else if editor.sample_finder_active() {
editor.sample_finder_next();
} else {
editor.search_next();
}
}
KeyCode::Char('p') if ctrl => {
if editor.completion_active() {
editor.completion_prev();
} else if editor.sample_finder_active() {
editor.sample_finder_prev();
} else {
editor.search_prev();
}
}
KeyCode::Char('s') if ctrl => {
ctx.dispatch(AppCommand::ToggleEditorStack);
}
KeyCode::Char('r') if ctrl => {
let script = ctx.app.editor_ctx.editor.lines().join("\n");
match ctx
.app
.execute_script_oneshot(&script, ctx.link, ctx.audio_tx)
{
Ok(()) => ctx
.app
.ui
.flash("Executed", 100, crate::state::FlashKind::Info),
Err(e) => ctx.app.ui.flash(
&format!("Error: {e}"),
200,
crate::state::FlashKind::Error,
),
}
}
KeyCode::Char('a') if ctrl => {
editor.select_all();
}
KeyCode::Char('c') if ctrl => {
editor.copy();
}
KeyCode::Char('x') if ctrl => {
editor.cut();
}
KeyCode::Char('v') if ctrl => {
editor.paste();
}
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down if shift => {
if !editor.is_selecting() {
editor.start_selection();
}
editor.input(Event::Key(key));
}
_ => {
editor.input(Event::Key(key));
}
}
if ctx.app.editor_ctx.show_stack {
crate::services::stack_preview::update_cache(&ctx.app.editor_ctx);
}
}
Modal::Preview => match key.code {
KeyCode::Esc | KeyCode::Char('p') => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Left => ctx.dispatch(AppCommand::PrevStep),
KeyCode::Right => ctx.dispatch(AppCommand::NextStep),
KeyCode::Up => ctx.dispatch(AppCommand::StepUp),
KeyCode::Down => ctx.dispatch(AppCommand::StepDown),
_ => {}
},
Modal::PatternProps {
bank,
pattern,
field,
name,
length,
speed,
quantization,
sync_mode,
} => {
let (bank, pattern) = (*bank, *pattern);
match key.code {
KeyCode::Up => *field = field.prev(),
KeyCode::Down | KeyCode::Tab => *field = field.next(),
KeyCode::Left => match field {
PatternPropsField::Speed => *speed = speed.prev(),
PatternPropsField::Quantization => *quantization = quantization.prev(),
PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(),
_ => {}
},
KeyCode::Right => match field {
PatternPropsField::Speed => *speed = speed.next(),
PatternPropsField::Quantization => *quantization = quantization.next(),
PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(),
_ => {}
},
KeyCode::Char(c) => match field {
PatternPropsField::Name => name.push(c),
PatternPropsField::Length if c.is_ascii_digit() => length.push(c),
_ => {}
},
KeyCode::Backspace => match field {
PatternPropsField::Name => {
name.pop();
}
PatternPropsField::Length => {
length.pop();
}
_ => {}
},
KeyCode::Enter => {
let name_val = if name.is_empty() {
None
} else {
Some(name.clone())
};
let length_val = length.parse().ok();
let speed_val = *speed;
let quant_val = *quantization;
let sync_val = *sync_mode;
ctx.dispatch(AppCommand::StagePatternProps {
bank,
pattern,
name: name_val,
length: length_val,
speed: speed_val,
quantization: quant_val,
sync_mode: sync_val,
});
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
_ => {}
}
}
Modal::KeybindingsHelp { scroll } => {
let bindings_count = crate::views::keybindings::bindings_for(ctx.app.page).len();
match key.code {
KeyCode::Esc | KeyCode::Char('?') => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Up | KeyCode::Char('k') => {
*scroll = scroll.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
*scroll = (*scroll + 1).min(bindings_count.saturating_sub(1));
}
KeyCode::PageUp => {
*scroll = scroll.saturating_sub(10);
}
KeyCode::PageDown => {
*scroll = (*scroll + 10).min(bindings_count.saturating_sub(1));
}
_ => {}
}
}
Modal::EuclideanDistribution {
bank,
pattern,
source_step,
field,
pulses,
steps,
rotation,
} => {
let (bank_val, pattern_val, source_step_val) = (*bank, *pattern, *source_step);
match key.code {
KeyCode::Up => *field = field.prev(),
KeyCode::Down | KeyCode::Tab => *field = field.next(),
KeyCode::Left => {
let target = match field {
EuclideanField::Pulses => pulses,
EuclideanField::Steps => steps,
EuclideanField::Rotation => rotation,
};
if let Ok(val) = target.parse::<usize>() {
*target = val.saturating_sub(1).to_string();
}
}
KeyCode::Right => {
let target = match field {
EuclideanField::Pulses => pulses,
EuclideanField::Steps => steps,
EuclideanField::Rotation => rotation,
};
if let Ok(val) = target.parse::<usize>() {
*target = (val + 1).min(128).to_string();
}
}
KeyCode::Char(c) if c.is_ascii_digit() => match field {
EuclideanField::Pulses => pulses.push(c),
EuclideanField::Steps => steps.push(c),
EuclideanField::Rotation => rotation.push(c),
},
KeyCode::Backspace => match field {
EuclideanField::Pulses => {
pulses.pop();
}
EuclideanField::Steps => {
steps.pop();
}
EuclideanField::Rotation => {
rotation.pop();
}
},
KeyCode::Enter => {
let pulses_val: usize = pulses.parse().unwrap_or(0);
let steps_val: usize = steps.parse().unwrap_or(0);
let rotation_val: usize = rotation.parse().unwrap_or(0);
if pulses_val > 0 && steps_val > 0 && pulses_val <= steps_val {
ctx.dispatch(AppCommand::ApplyEuclideanDistribution {
bank: bank_val,
pattern: pattern_val,
source_step: source_step_val,
pulses: pulses_val,
steps: steps_val,
rotation: rotation_val,
});
ctx.dispatch(AppCommand::CloseModal);
} else {
ctx.dispatch(AppCommand::SetStatus(
"Invalid: pulses must be > 0 and <= steps".to_string(),
));
}
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
_ => {}
}
}
Modal::None => unreachable!(),
}
InputResult::Continue
}
fn execute_confirm(ctx: &mut InputContext, action: &ConfirmAction) -> InputResult {
match action {
ConfirmAction::Quit => return InputResult::Quit,
ConfirmAction::DeleteStep { bank, pattern, step } => {
ctx.dispatch(AppCommand::DeleteStep { bank: *bank, pattern: *pattern, step: *step });
}
ConfirmAction::DeleteSteps { bank, pattern, steps } => {
ctx.dispatch(AppCommand::DeleteSteps { bank: *bank, pattern: *pattern, steps: steps.clone() });
}
ConfirmAction::ResetPattern { bank, pattern } => {
ctx.dispatch(AppCommand::ResetPattern { bank: *bank, pattern: *pattern });
}
ConfirmAction::ResetBank { bank } => {
ctx.dispatch(AppCommand::ResetBank { bank: *bank });
}
ConfirmAction::ResetPatterns { bank, patterns } => {
ctx.dispatch(AppCommand::ResetPatterns { bank: *bank, patterns: patterns.clone() });
}
ConfirmAction::ResetBanks { banks } => {
ctx.dispatch(AppCommand::ResetBanks { banks: banks.clone() });
}
}
ctx.dispatch(AppCommand::CloseModal);
InputResult::Continue
}
fn rename_command(target: &RenameTarget, name: Option<String>) -> AppCommand {
match target {
RenameTarget::Bank { bank } => AppCommand::RenameBank { bank: *bank, name },
RenameTarget::Pattern { bank, pattern } => AppCommand::RenamePattern {
bank: *bank, pattern: *pattern, name,
},
RenameTarget::Step { bank, pattern, step } => AppCommand::RenameStep {
bank: *bank, pattern: *pattern, step: *step, name,
},
}
}

178
src/input/options_page.rs Normal file
View File

@@ -0,0 +1,178 @@
use crossterm::event::{KeyCode, KeyEvent};
use std::sync::atomic::Ordering;
use super::{InputContext, InputResult};
use crate::commands::AppCommand;
use crate::state::{ConfirmAction, Modal, OptionsFocus};
pub(super) fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
match key.code {
KeyCode::Char('q') => {
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
action: ConfirmAction::Quit,
selected: false,
}));
}
KeyCode::Down | KeyCode::Tab => ctx.dispatch(AppCommand::OptionsNextFocus),
KeyCode::Up | KeyCode::BackTab => ctx.dispatch(AppCommand::OptionsPrevFocus),
KeyCode::Left | KeyCode::Right => {
match ctx.app.options.focus {
OptionsFocus::ColorScheme => {
let new_scheme = if key.code == KeyCode::Left {
ctx.app.ui.color_scheme.prev()
} else {
ctx.app.ui.color_scheme.next()
};
ctx.dispatch(AppCommand::SetColorScheme(new_scheme));
}
OptionsFocus::HueRotation => {
let delta = if key.code == KeyCode::Left { -5.0 } else { 5.0 };
let new_rotation = (ctx.app.ui.hue_rotation + delta).rem_euclid(360.0);
ctx.dispatch(AppCommand::SetHueRotation(new_rotation));
}
OptionsFocus::RefreshRate => ctx.dispatch(AppCommand::ToggleRefreshRate),
OptionsFocus::RuntimeHighlight => {
ctx.dispatch(AppCommand::ToggleRuntimeHighlight);
}
OptionsFocus::ShowScope => {
ctx.dispatch(AppCommand::ToggleScope);
}
OptionsFocus::ShowSpectrum => {
ctx.dispatch(AppCommand::ToggleSpectrum);
}
OptionsFocus::ShowCompletion => {
ctx.dispatch(AppCommand::ToggleCompletion);
}
OptionsFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
OptionsFocus::StartStopSync => ctx
.link
.set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()),
OptionsFocus::Quantum => {
let delta = if key.code == KeyCode::Left { -1.0 } else { 1.0 };
ctx.link.set_quantum(ctx.link.quantum() + delta);
}
OptionsFocus::MidiOutput0
| OptionsFocus::MidiOutput1
| OptionsFocus::MidiOutput2
| OptionsFocus::MidiOutput3 => {
let slot = match ctx.app.options.focus {
OptionsFocus::MidiOutput0 => 0,
OptionsFocus::MidiOutput1 => 1,
OptionsFocus::MidiOutput2 => 2,
OptionsFocus::MidiOutput3 => 3,
_ => 0,
};
let all_devices = crate::midi::list_midi_outputs();
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
.iter()
.enumerate()
.filter(|(idx, _)| {
ctx.app.midi.selected_outputs[slot] == Some(*idx)
|| !ctx
.app
.midi
.selected_outputs
.iter()
.enumerate()
.any(|(s, sel)| s != slot && *sel == Some(*idx))
})
.collect();
let total_options = available.len() + 1;
let current_pos = ctx.app.midi.selected_outputs[slot]
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
.map(|p| p + 1)
.unwrap_or(0);
let new_pos = if key.code == KeyCode::Left {
if current_pos == 0 {
total_options - 1
} else {
current_pos - 1
}
} else {
(current_pos + 1) % total_options
};
if new_pos == 0 {
ctx.app.midi.disconnect_output(slot);
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI output {slot}: disconnected"
)));
} else {
let (device_idx, device) = available[new_pos - 1];
if ctx.app.midi.connect_output(slot, device_idx).is_ok() {
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI output {}: {}",
slot, device.name
)));
}
}
}
OptionsFocus::MidiInput0
| OptionsFocus::MidiInput1
| OptionsFocus::MidiInput2
| OptionsFocus::MidiInput3 => {
let slot = match ctx.app.options.focus {
OptionsFocus::MidiInput0 => 0,
OptionsFocus::MidiInput1 => 1,
OptionsFocus::MidiInput2 => 2,
OptionsFocus::MidiInput3 => 3,
_ => 0,
};
let all_devices = crate::midi::list_midi_inputs();
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
.iter()
.enumerate()
.filter(|(idx, _)| {
ctx.app.midi.selected_inputs[slot] == Some(*idx)
|| !ctx
.app
.midi
.selected_inputs
.iter()
.enumerate()
.any(|(s, sel)| s != slot && *sel == Some(*idx))
})
.collect();
let total_options = available.len() + 1;
let current_pos = ctx.app.midi.selected_inputs[slot]
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
.map(|p| p + 1)
.unwrap_or(0);
let new_pos = if key.code == KeyCode::Left {
if current_pos == 0 {
total_options - 1
} else {
current_pos - 1
}
} else {
(current_pos + 1) % total_options
};
if new_pos == 0 {
ctx.app.midi.disconnect_input(slot);
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI input {slot}: disconnected"
)));
} else {
let (device_idx, device) = available[new_pos - 1];
if ctx.app.midi.connect_input(slot, device_idx).is_ok() {
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI input {}: {}",
slot, device.name
)));
}
}
}
}
ctx.app.save_settings(ctx.link);
}
KeyCode::Char(' ') => {
ctx.dispatch(AppCommand::TogglePlaying);
ctx.playing
.store(ctx.app.playback.playing, Ordering::Relaxed);
}
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
_ => {}
}
InputResult::Continue
}

95
src/input/panel.rs Normal file
View File

@@ -0,0 +1,95 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::{InputContext, InputResult};
use crate::commands::AppCommand;
use crate::engine::AudioCommand;
use crate::state::SidePanel;
use cagire_ratatui::TreeLineKind;
pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let state = match &mut ctx.app.panel.side {
Some(SidePanel::SampleBrowser(s)) => s,
None => return InputResult::Continue,
};
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if state.search_active {
match key.code {
KeyCode::Esc => {
state.clear_search();
}
KeyCode::Backspace => {
state.search_query.pop();
state.update_search();
}
KeyCode::Enter => {
state.search_active = false;
}
KeyCode::Char(c) => {
state.search_query.push(c);
state.update_search();
}
_ => {}
}
} else if ctrl {
match key.code {
KeyCode::Up => {
for _ in 0..10 {
state.move_up();
}
}
KeyCode::Down => {
for _ in 0..10 {
state.move_down(30);
}
}
_ => {}
}
} else {
match key.code {
KeyCode::Up | KeyCode::Char('k') => state.move_up(),
KeyCode::Down | KeyCode::Char('j') => state.move_down(30),
KeyCode::PageUp => {
for _ in 0..20 {
state.move_up();
}
}
KeyCode::PageDown => {
for _ in 0..20 {
state.move_down(30);
}
}
KeyCode::Enter | KeyCode::Right => {
if let Some(entry) = state.current_entry() {
match entry.kind {
TreeLineKind::File => {
let folder = &entry.folder;
let idx = entry.index;
let cmd = format!("/sound/{folder}/n/{idx}/gain/1.00/dur/1");
let _ = ctx
.audio_tx
.load()
.send(AudioCommand::Evaluate { cmd, time: None });
}
_ => state.toggle_expand(),
}
}
}
KeyCode::Left => state.collapse_at_cursor(),
KeyCode::Char('/') => state.activate_search(),
KeyCode::Esc => {
if state.has_filter() {
state.clear_filter();
} else {
ctx.dispatch(AppCommand::ClosePanel);
}
}
KeyCode::Tab => {
ctx.dispatch(AppCommand::ClosePanel);
}
_ => {}
}
}
InputResult::Continue
}

245
src/input/patterns_page.rs Normal file
View File

@@ -0,0 +1,245 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::sync::atomic::Ordering;
use super::{InputContext, InputResult};
use crate::commands::AppCommand;
use crate::state::{ConfirmAction, Modal, PatternsColumn, RenameTarget};
pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Up if shift => {
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
if ctx.app.patterns_nav.bank_anchor.is_none() {
ctx.app.patterns_nav.bank_anchor = Some(ctx.app.patterns_nav.bank_cursor);
}
}
PatternsColumn::Patterns => {
if ctx.app.patterns_nav.pattern_anchor.is_none() {
ctx.app.patterns_nav.pattern_anchor =
Some(ctx.app.patterns_nav.pattern_cursor);
}
}
}
ctx.app.patterns_nav.move_up_clamped();
}
KeyCode::Down if shift => {
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
if ctx.app.patterns_nav.bank_anchor.is_none() {
ctx.app.patterns_nav.bank_anchor = Some(ctx.app.patterns_nav.bank_cursor);
}
}
PatternsColumn::Patterns => {
if ctx.app.patterns_nav.pattern_anchor.is_none() {
ctx.app.patterns_nav.pattern_anchor =
Some(ctx.app.patterns_nav.pattern_cursor);
}
}
}
ctx.app.patterns_nav.move_down_clamped();
}
KeyCode::Up => {
ctx.app.patterns_nav.clear_selection();
ctx.dispatch(AppCommand::PatternsCursorUp);
}
KeyCode::Down => {
ctx.app.patterns_nav.clear_selection();
ctx.dispatch(AppCommand::PatternsCursorDown);
}
KeyCode::Left => ctx.dispatch(AppCommand::PatternsCursorLeft),
KeyCode::Right => ctx.dispatch(AppCommand::PatternsCursorRight),
KeyCode::Esc => {
if ctx.app.patterns_nav.has_selection() {
ctx.app.patterns_nav.clear_selection();
} else if !ctx.app.playback.staged_changes.is_empty()
|| !ctx.app.playback.staged_mute_changes.is_empty()
|| !ctx.app.playback.staged_prop_changes.is_empty()
{
ctx.dispatch(AppCommand::ClearStagedChanges);
} else {
ctx.dispatch(AppCommand::PatternsBack);
}
}
KeyCode::Enter => {
if !ctx.app.patterns_nav.has_selection() {
ctx.dispatch(AppCommand::PatternsEnter);
}
}
KeyCode::Char('p') => {
if ctx.app.patterns_nav.column == PatternsColumn::Patterns {
let bank = ctx.app.patterns_nav.bank_cursor;
for pattern in ctx.app.patterns_nav.selected_patterns() {
ctx.app.stage_pattern_toggle(bank, pattern, ctx.snapshot);
}
}
}
KeyCode::Char(' ') => {
ctx.dispatch(AppCommand::TogglePlaying);
ctx.playing
.store(ctx.app.playback.playing, Ordering::Relaxed);
}
KeyCode::Char('c') if !ctrl => {
let mute_changed = ctx.app.commit_staged_changes();
if mute_changed {
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
}
KeyCode::Char('q') => {
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
action: ConfirmAction::Quit,
selected: false,
}));
}
KeyCode::Char('c') if ctrl => {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
let banks = ctx.app.patterns_nav.selected_banks();
if banks.len() > 1 {
ctx.dispatch(AppCommand::CopyBanks { banks });
} else {
ctx.dispatch(AppCommand::CopyBank { bank });
}
}
PatternsColumn::Patterns => {
let patterns = ctx.app.patterns_nav.selected_patterns();
if patterns.len() > 1 {
ctx.dispatch(AppCommand::CopyPatterns { bank, patterns });
} else {
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::CopyPattern { bank, pattern });
}
}
}
}
KeyCode::Char('v') if ctrl => {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
if ctx.app.copied_banks.as_ref().is_some_and(|v| v.len() > 1) {
ctx.dispatch(AppCommand::PasteBanks { start: bank });
} else {
ctx.dispatch(AppCommand::PasteBank { bank });
}
}
PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor;
if ctx
.app
.copied_patterns
.as_ref()
.is_some_and(|v| v.len() > 1)
{
ctx.dispatch(AppCommand::PastePatterns {
bank,
start: pattern,
});
} else {
ctx.dispatch(AppCommand::PastePattern { bank, pattern });
}
}
}
}
KeyCode::Delete | KeyCode::Backspace => {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
let banks = ctx.app.patterns_nav.selected_banks();
if banks.len() > 1 {
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
action: ConfirmAction::ResetBanks { banks },
selected: false,
}));
} else {
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
action: ConfirmAction::ResetBank { bank },
selected: false,
}));
}
}
PatternsColumn::Patterns => {
let patterns = ctx.app.patterns_nav.selected_patterns();
if patterns.len() > 1 {
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
action: ConfirmAction::ResetPatterns { bank, patterns },
selected: false,
}));
} else {
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
action: ConfirmAction::ResetPattern { bank, pattern },
selected: false,
}));
}
}
}
}
KeyCode::Char('r') => {
if !ctx.app.patterns_nav.has_selection() {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
let current_name = ctx.app.project_state.project.banks[bank]
.name
.clone()
.unwrap_or_default();
ctx.dispatch(AppCommand::OpenModal(Modal::Rename {
target: RenameTarget::Bank { bank },
name: current_name,
}));
}
PatternsColumn::Patterns => {
let pattern = ctx.app.patterns_nav.pattern_cursor;
let current_name = ctx.app.project_state.project.banks[bank].patterns
[pattern]
.name
.clone()
.unwrap_or_default();
ctx.dispatch(AppCommand::OpenModal(Modal::Rename {
target: RenameTarget::Pattern { bank, pattern },
name: current_name,
}));
}
}
}
}
KeyCode::Char('e') if !ctrl => {
if ctx.app.patterns_nav.column == PatternsColumn::Patterns
&& !ctx.app.patterns_nav.has_selection()
{
let bank = ctx.app.patterns_nav.bank_cursor;
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern });
}
}
KeyCode::Char('m') => {
let bank = ctx.app.patterns_nav.bank_cursor;
for pattern in ctx.app.patterns_nav.selected_patterns() {
ctx.dispatch(AppCommand::StageMute { bank, pattern });
}
}
KeyCode::Char('x') => {
let bank = ctx.app.patterns_nav.bank_cursor;
for pattern in ctx.app.patterns_nav.selected_patterns() {
ctx.dispatch(AppCommand::StageSolo { bank, pattern });
}
}
KeyCode::Char('M') => {
ctx.dispatch(AppCommand::ClearMutes);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
KeyCode::Char('X') => {
ctx.dispatch(AppCommand::ClearSolos);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
_ => {}
}
InputResult::Continue
}

View File

@@ -98,7 +98,7 @@ fn format_value(v: &Value) -> String {
} }
Value::Str(s, _) => format!("\"{s}\""), Value::Str(s, _) => format!("\"{s}\""),
Value::Quotation(..) => "[...]".to_string(), Value::Quotation(..) => "[...]".to_string(),
Value::CycleList(items) => { Value::CycleList(items) | Value::ArpList(items) => {
let inner: Vec<String> = items.iter().map(format_value).collect(); let inner: Vec<String> = items.iter().map(format_value).collect();
format!("({})", inner.join(" ")) format!("({})", inner.join(" "))
} }

View File

@@ -36,7 +36,7 @@ pub use editor::{
PatternPropsField, StackCache, PatternPropsField, StackCache,
}; };
pub use live_keys::LiveKeyState; pub use live_keys::LiveKeyState;
pub use modal::Modal; pub use modal::{ConfirmAction, Modal, RenameTarget};
pub use options::{OptionsFocus, OptionsState}; pub use options::{OptionsFocus, OptionsState};
pub use panel::{PanelFocus, PanelState, SidePanel}; pub use panel::{PanelFocus, PanelState, SidePanel};
pub use patterns_nav::{PatternsColumn, PatternsNav}; pub use patterns_nav::{PatternsColumn, PatternsNav};

View File

@@ -2,56 +2,61 @@ use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
use crate::state::editor::{EuclideanField, PatternField, PatternPropsField}; use crate::state::editor::{EuclideanField, PatternField, PatternPropsField};
use crate::state::file_browser::FileBrowserState; use crate::state::file_browser::FileBrowserState;
#[derive(Clone, PartialEq, Eq)]
pub enum ConfirmAction {
Quit,
DeleteStep { bank: usize, pattern: usize, step: usize },
DeleteSteps { bank: usize, pattern: usize, steps: Vec<usize> },
ResetPattern { bank: usize, pattern: usize },
ResetBank { bank: usize },
ResetPatterns { bank: usize, patterns: Vec<usize> },
ResetBanks { banks: Vec<usize> },
}
impl ConfirmAction {
pub fn message(&self) -> String {
match self {
Self::Quit => "Quit?".into(),
Self::DeleteStep { step, .. } => format!("Delete step {}?", step + 1),
Self::DeleteSteps { steps, .. } => {
let nums: Vec<String> = steps.iter().map(|s| format!("{:02}", s + 1)).collect();
format!("Delete steps {}?", nums.join(", "))
}
Self::ResetPattern { pattern, .. } => format!("Reset pattern {}?", pattern + 1),
Self::ResetBank { bank } => format!("Reset bank {}?", bank + 1),
Self::ResetPatterns { patterns, .. } => format!("Reset {} patterns?", patterns.len()),
Self::ResetBanks { banks } => format!("Reset {} banks?", banks.len()),
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub enum RenameTarget {
Bank { bank: usize },
Pattern { bank: usize, pattern: usize },
Step { bank: usize, pattern: usize, step: usize },
}
impl RenameTarget {
pub fn title(&self) -> String {
match self {
Self::Bank { bank } => format!("Rename Bank {:02}", bank + 1),
Self::Pattern { bank, pattern } => format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1),
Self::Step { step, .. } => format!("Name Step {:02}", step + 1),
}
}
}
#[derive(Clone, PartialEq, Eq)] #[derive(Clone, PartialEq, Eq)]
pub enum Modal { pub enum Modal {
None, None,
ConfirmQuit { Confirm {
selected: bool, action: ConfirmAction,
},
ConfirmDeleteStep {
bank: usize,
pattern: usize,
step: usize,
selected: bool,
},
ConfirmDeleteSteps {
bank: usize,
pattern: usize,
steps: Vec<usize>,
selected: bool,
},
ConfirmResetPattern {
bank: usize,
pattern: usize,
selected: bool,
},
ConfirmResetBank {
bank: usize,
selected: bool,
},
ConfirmResetPatterns {
bank: usize,
patterns: Vec<usize>,
selected: bool,
},
ConfirmResetBanks {
banks: Vec<usize>,
selected: bool, selected: bool,
}, },
FileBrowser(Box<FileBrowserState>), FileBrowser(Box<FileBrowserState>),
RenameBank { Rename {
bank: usize, target: RenameTarget,
name: String,
},
RenamePattern {
bank: usize,
pattern: usize,
name: String,
},
RenameStep {
bank: usize,
pattern: usize,
step: usize,
name: String, name: String,
}, },
SetPattern { SetPattern {

View File

@@ -1,7 +1,7 @@
use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
use ratatui::text::{Line as RLine, Span}; use ratatui::text::{Line as RLine, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame; use ratatui::Frame;
use crate::app::App; use crate::app::App;
@@ -9,6 +9,7 @@ use crate::model::categories::{get_category_name, CatEntry, CATEGORIES};
use crate::model::{Word, WORDS}; use crate::model::{Word, WORDS};
use crate::state::DictFocus; use crate::state::DictFocus;
use crate::theme; use crate::theme;
use crate::widgets::{render_search_bar, CategoryItem, CategoryList};
use CatEntry::{Category, Section}; use CatEntry::{Category, Section};
@@ -47,84 +48,35 @@ fn render_categories(frame: &mut Frame, app: &App, area: Rect, dimmed: bool) {
let theme = theme::get(); let theme = theme::get();
let focused = app.ui.dict_focus == DictFocus::Categories && !dimmed; let focused = app.ui.dict_focus == DictFocus::Categories && !dimmed;
let visible_height = area.height.saturating_sub(2) as usize; let items: Vec<CategoryItem> = CATEGORIES
let total_items = CATEGORIES.len();
// Find the visual index of the selected category (including sections)
let selected_visual_idx = {
let mut visual = 0;
let mut cat_count = 0;
for entry in CATEGORIES.iter() {
if let Category(_) = entry {
if cat_count == app.ui.dict_category {
break;
}
cat_count += 1;
}
visual += 1;
}
visual
};
// Calculate scroll to keep selection visible (centered when possible)
let scroll = if selected_visual_idx < visible_height / 2 {
0
} else if selected_visual_idx > total_items.saturating_sub(visible_height / 2) {
total_items.saturating_sub(visible_height)
} else {
selected_visual_idx.saturating_sub(visible_height / 2)
};
// Count categories before the scroll offset to track cat_idx correctly
let mut cat_idx = CATEGORIES
.iter() .iter()
.take(scroll)
.filter(|e| matches!(e, Category(_)))
.count();
let items: Vec<ListItem> = CATEGORIES
.iter()
.skip(scroll)
.take(visible_height)
.map(|entry| match entry { .map(|entry| match entry {
Section(name) => { Section(name) => CategoryItem {
let style = Style::new().fg(theme.ui.text_dim); label: name,
ListItem::new(format!("{name}")).style(style) is_section: true,
} },
Category(name) => { Category(name) => CategoryItem {
let is_selected = cat_idx == app.ui.dict_category; label: name,
let style = if dimmed { is_section: false,
Style::new().fg(theme.dict.category_dimmed) },
} else if is_selected && focused {
Style::new()
.fg(theme.dict.category_focused)
.add_modifier(Modifier::BOLD)
} else if is_selected {
Style::new().fg(theme.dict.category_selected)
} else {
Style::new().fg(theme.dict.category_normal)
};
let prefix = if is_selected && !dimmed { "> " } else { " " };
cat_idx += 1;
ListItem::new(format!("{prefix}{name}")).style(style)
}
}) })
.collect(); .collect();
let border_color = if focused { theme.dict.border_focused } else { theme.dict.border_normal }; let mut list = CategoryList::new(&items, app.ui.dict_category)
let block = Block::default() .focused(focused)
.borders(Borders::ALL)
.border_style(Style::new().fg(border_color))
.title("Categories"); .title("Categories");
let list = List::new(items).block(block);
frame.render_widget(list, area); if dimmed {
list = list.dimmed(theme.dict.category_dimmed);
}
list.render(frame, area);
} }
fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) { fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) {
let theme = theme::get(); let theme = theme::get();
let focused = app.ui.dict_focus == DictFocus::Words; let focused = app.ui.dict_focus == DictFocus::Words;
// Filter words by search query or category
let words: Vec<&Word> = if is_searching { let words: Vec<&Word> = if is_searching {
let query = app.ui.dict_search_query.to_lowercase(); let query = app.ui.dict_search_query.to_lowercase();
WORDS WORDS
@@ -142,7 +94,6 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) {
.collect() .collect()
}; };
// Split area for search bar when search is active or has query
let show_search = app.ui.dict_search_active || is_searching; let show_search = app.ui.dict_search_active || is_searching;
let (search_area, content_area) = if show_search { let (search_area, content_area) = if show_search {
let [s, c] = let [s, c] =
@@ -152,9 +103,8 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) {
(None, area) (None, area)
}; };
// Render search bar
if let Some(sa) = search_area { if let Some(sa) = search_area {
render_search_bar(frame, app, sa); render_search_bar(frame, sa, &app.ui.dict_search_query, app.ui.dict_search_active);
} }
let content_width = content_area.width.saturating_sub(2) as usize; let content_width = content_area.width.saturating_sub(2) as usize;
@@ -229,17 +179,3 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) {
.block(block); .block(block);
frame.render_widget(para, content_area); frame.render_widget(para, content_area);
} }
fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get();
let style = if app.ui.dict_search_active {
Style::new().fg(theme.search.active)
} else {
Style::new().fg(theme.search.inactive)
};
let cursor = if app.ui.dict_search_active { "_" } else { "" };
let text = format!(" /{}{}", app.ui.dict_search_query, cursor);
let line = RLine::from(Span::styled(text, style));
frame.render_widget(Paragraph::new(vec![line]), area);
}

View File

@@ -8,7 +8,9 @@ use ratatui::Frame;
use crate::app::App; use crate::app::App;
use crate::state::{DeviceKind, EngineSection, SettingKind}; use crate::state::{DeviceKind, EngineSection, SettingKind};
use crate::theme; use crate::theme;
use crate::widgets::{Orientation, Scope, Spectrum}; use crate::widgets::{
render_scroll_indicators, render_section_header, IndicatorAlign, Orientation, Scope, Spectrum,
};
pub fn render(frame: &mut Frame, app: &App, area: Rect) { pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let [left_col, _, right_col] = Layout::horizontal([ let [left_col, _, right_col] = Layout::horizontal([
@@ -122,27 +124,15 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
render_samples(frame, app, samples_area); render_samples(frame, app, samples_area);
} }
// Scroll indicators render_scroll_indicators(
let indicator_style = Style::new().fg(theme.engine.scroll_indicator); frame,
let indicator_x = padded.x + padded.width.saturating_sub(1); padded,
scroll_offset,
if scroll_offset > 0 { max_visible,
let up_indicator = Paragraph::new("").style(indicator_style); total_lines,
frame.render_widget(up_indicator, Rect::new(indicator_x, padded.y, 1, 1)); theme.engine.scroll_indicator,
} IndicatorAlign::Right,
);
if scroll_offset + max_visible < total_lines {
let down_indicator = Paragraph::new("").style(indicator_style);
frame.render_widget(
down_indicator,
Rect::new(
indicator_x,
padded.y + padded.height.saturating_sub(1),
1,
1,
),
);
}
} }
fn render_visualizers(frame: &mut Frame, app: &App, area: Rect) { fn render_visualizers(frame: &mut Frame, app: &App, area: Rect) {
@@ -210,30 +200,6 @@ fn devices_section_height(app: &App) -> u16 {
3 + output_h.max(input_h) 3 + output_h.max(input_h)
} }
fn render_section_header(frame: &mut Frame, title: &str, focused: bool, area: Rect) {
let theme = theme::get();
let [header_area, divider_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
let header_style = if focused {
Style::new()
.fg(theme.engine.header_focused)
.add_modifier(Modifier::BOLD)
} else {
Style::new()
.fg(theme.engine.header)
.add_modifier(Modifier::BOLD)
};
frame.render_widget(Paragraph::new(title).style(header_style), header_area);
let divider = "".repeat(area.width as usize);
frame.render_widget(
Paragraph::new(divider).style(Style::new().fg(theme.engine.divider)),
divider_area,
);
}
fn render_devices(frame: &mut Frame, app: &App, area: Rect) { fn render_devices(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get(); let theme = theme::get();
let section_focused = app.audio.section == EngineSection::Devices; let section_focused = app.audio.section == EngineSection::Devices;

View File

@@ -2,7 +2,7 @@ use cagire_markdown::{CodeHighlighter, MarkdownTheme};
use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line as RLine, Span}; use ratatui::text::{Line as RLine, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Padding, Paragraph, Wrap}; use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap};
use ratatui::Frame; use ratatui::Frame;
#[cfg(not(feature = "desktop"))] #[cfg(not(feature = "desktop"))]
use tui_big_text::{BigText, PixelSize}; use tui_big_text::{BigText, PixelSize};
@@ -12,6 +12,7 @@ use crate::model::docs::{get_topic, DocEntry, DOCS};
use crate::state::HelpFocus; use crate::state::HelpFocus;
use crate::theme; use crate::theme;
use crate::views::highlight; use crate::views::highlight;
use crate::widgets::{render_search_bar, CategoryItem, CategoryList};
use DocEntry::{Section, Topic}; use DocEntry::{Section, Topic};
@@ -100,80 +101,28 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) {
fn render_topics(frame: &mut Frame, app: &App, area: Rect) { fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get(); let theme = theme::get();
let focused = app.ui.help_focus == HelpFocus::Topics;
let visible_height = area.height.saturating_sub(2) as usize; let items: Vec<CategoryItem> = DOCS
let total_items = DOCS.len();
// Find the visual index of the selected topic (including sections)
let selected_visual_idx = {
let mut visual = 0;
let mut topic_count = 0;
for entry in DOCS.iter() {
if let Topic(_, _) = entry {
if topic_count == app.ui.help_topic {
break;
}
topic_count += 1;
}
visual += 1;
}
visual
};
// Calculate scroll to keep selection visible (centered when possible)
let scroll = if selected_visual_idx < visible_height / 2 {
0
} else if selected_visual_idx > total_items.saturating_sub(visible_height / 2) {
total_items.saturating_sub(visible_height)
} else {
selected_visual_idx.saturating_sub(visible_height / 2)
};
// Count topics before the scroll offset to track topic_idx correctly
let mut topic_idx = DOCS
.iter() .iter()
.take(scroll)
.filter(|e| matches!(e, Topic(_, _)))
.count();
let items: Vec<ListItem> = DOCS
.iter()
.skip(scroll)
.take(visible_height)
.map(|entry| match entry { .map(|entry| match entry {
Section(name) => { Section(name) => CategoryItem {
let style = Style::new().fg(theme.ui.text_dim); label: name,
ListItem::new(format!("{name}")).style(style) is_section: true,
} },
Topic(name, _) => { Topic(name, _) => CategoryItem {
let selected = topic_idx == app.ui.help_topic; label: name,
let style = if selected { is_section: false,
Style::new() },
.fg(theme.dict.category_selected)
.add_modifier(Modifier::BOLD)
} else {
Style::new().fg(theme.ui.text_primary)
};
let prefix = if selected { "> " } else { " " };
topic_idx += 1;
ListItem::new(format!("{prefix}{name}")).style(style)
}
}) })
.collect(); .collect();
let focused = app.ui.help_focus == HelpFocus::Topics; CategoryList::new(&items, app.ui.help_topic)
let border_color = if focused { .focused(focused)
theme.dict.border_focused .title("Topics")
} else { .selected_color(theme.dict.category_selected)
theme.dict.border_normal .normal_color(theme.ui.text_primary)
}; .render(frame, area);
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(border_color))
.title("Topics"),
);
frame.render_widget(list, area);
} }
const WELCOME_TOPIC: usize = 0; const WELCOME_TOPIC: usize = 0;
@@ -237,7 +186,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
let content_area = if has_search_bar { let content_area = if has_search_bar {
let [content, search] = let [content, search] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(md_area); Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(md_area);
render_search_bar(frame, app, search); render_search_bar(frame, search, &app.ui.help_search_query, app.ui.help_search_active);
content content
} else { } else {
md_area md_area
@@ -292,18 +241,6 @@ fn wrapped_line_count(line: &RLine, width: usize) -> usize {
} }
} }
fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get();
let style = if app.ui.help_search_active {
Style::new().fg(theme.search.active)
} else {
Style::new().fg(theme.search.inactive)
};
let cursor = if app.ui.help_search_active { "" } else { "" };
let text = format!(" /{}{cursor}", app.ui.help_search_query);
frame.render_widget(Paragraph::new(text).style(style), area);
}
fn highlight_line<'a>(line: RLine<'a>, query: &str) -> RLine<'a> { fn highlight_line<'a>(line: RLine<'a>, query: &str) -> RLine<'a> {
let theme = theme::get(); let theme = theme::get();
let mut result: Vec<Span<'a>> = Vec::new(); let mut result: Vec<Span<'a>> = Vec::new();

View File

@@ -1,12 +1,12 @@
use crate::page::Page; use crate::page::Page;
pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str)> { pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str)> {
let mut bindings = Vec::new(); let mut bindings = vec![
("F1F6", "Go to view", "Dict/Patterns/Options/Help/Sequencer/Engine"),
// Global bindings ("Ctrl+←→↑↓", "Navigate", "Switch between adjacent views"),
bindings.push(("Ctrl+←→↑↓", "Navigate", "Switch between views")); ("q", "Quit", "Quit application"),
bindings.push(("q", "Quit", "Quit application")); ("?", "Keybindings", "Show this help"),
bindings.push(("?", "Keybindings", "Show this help")); ];
// Page-specific bindings // Page-specific bindings
match page { match page {

View File

@@ -9,6 +9,7 @@ use crate::engine::LinkState;
use crate::midi; use crate::midi;
use crate::state::OptionsFocus; use crate::state::OptionsFocus;
use crate::theme; use crate::theme;
use crate::widgets::{render_scroll_indicators, IndicatorAlign};
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let theme = theme::get(); let theme = theme::get();
@@ -243,24 +244,15 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
frame.render_widget(Paragraph::new(visible_lines), padded); frame.render_widget(Paragraph::new(visible_lines), padded);
let indicator_style = Style::new().fg(theme.ui.text_dim); render_scroll_indicators(
let indicator_x = padded.x + padded.width.saturating_sub(1); frame,
padded,
if scroll_offset > 0 { scroll_offset,
let up_indicator = Paragraph::new("").style(indicator_style); visible_end - scroll_offset,
frame.render_widget( total_lines,
up_indicator, theme.ui.text_dim,
Rect::new(indicator_x, padded.y, 1, 1), IndicatorAlign::Right,
); );
}
if visible_end < total_lines {
let down_indicator = Paragraph::new("").style(indicator_style);
frame.render_widget(
down_indicator,
Rect::new(indicator_x, padded.y + padded.height.saturating_sub(1), 1, 1),
);
}
} }
fn render_section_header(title: &str, theme: &theme::ThemeColors) -> Line<'static> { fn render_section_header(title: &str, theme: &theme::ThemeColors) -> Line<'static> {

View File

@@ -9,6 +9,7 @@ use crate::engine::SequencerSnapshot;
use crate::model::{MAX_BANKS, MAX_PATTERNS}; use crate::model::{MAX_BANKS, MAX_PATTERNS};
use crate::state::PatternsColumn; use crate::state::PatternsColumn;
use crate::theme; use crate::theme;
use crate::widgets::{render_scroll_indicators, IndicatorAlign};
const MIN_ROW_HEIGHT: u16 = 1; const MIN_ROW_HEIGHT: u16 = 1;
@@ -171,21 +172,15 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
frame.render_widget(para, text_area); frame.render_widget(para, text_area);
} }
// Scroll indicators render_scroll_indicators(
let indicator_style = Style::new().fg(theme.ui.text_muted); frame,
if scroll_offset > 0 { inner,
let indicator = Paragraph::new("") scroll_offset,
.style(indicator_style) visible_count,
.alignment(ratatui::layout::Alignment::Center); MAX_BANKS,
frame.render_widget(indicator, Rect { height: 1, ..inner }); theme.ui.text_muted,
} IndicatorAlign::Center,
if scroll_offset + visible_count < MAX_BANKS { );
let y = inner.y + inner.height.saturating_sub(1);
let indicator = Paragraph::new("")
.style(indicator_style)
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(indicator, Rect { y, height: 1, ..inner });
}
} }
fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
@@ -419,19 +414,13 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
frame.render_widget(Paragraph::new(Line::from(spans)), text_area); frame.render_widget(Paragraph::new(Line::from(spans)), text_area);
} }
// Scroll indicators render_scroll_indicators(
let indicator_style = Style::new().fg(theme.ui.text_muted); frame,
if scroll_offset > 0 { inner,
let indicator = Paragraph::new("") scroll_offset,
.style(indicator_style) visible_count,
.alignment(ratatui::layout::Alignment::Center); MAX_PATTERNS,
frame.render_widget(indicator, Rect { height: 1, ..inner }); theme.ui.text_muted,
} IndicatorAlign::Center,
if scroll_offset + visible_count < MAX_PATTERNS { );
let y = inner.y + inner.height.saturating_sub(1);
let indicator = Paragraph::new("")
.style(indicator_style)
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(indicator, Rect { y, height: 1, ..inner });
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
pub use cagire_ratatui::{ pub use cagire_ratatui::{
ActivePatterns, ConfirmModal, FileBrowserModal, ModalFrame, MuteStatus, NavMinimap, NavTile, hint_line, render_props_form, render_scroll_indicators, render_search_bar,
Orientation, SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter, Waveform, render_section_header, ActivePatterns, CategoryItem, CategoryList, ConfirmModal,
FileBrowserModal, IndicatorAlign, ModalFrame, MuteStatus, NavMinimap, NavTile, Orientation,
SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter, Waveform,
}; };

View File

@@ -207,3 +207,124 @@ fn at_records_selected_spans() {
assert_eq!(&script[trace.selected_spans[2].start as usize..trace.selected_spans[2].end as usize], "0.5"); assert_eq!(&script[trace.selected_spans[2].start as usize..trace.selected_spans[2].end as usize], "0.5");
assert_eq!(&script[trace.selected_spans[4].start as usize..trace.selected_spans[4].end as usize], "0.75"); assert_eq!(&script[trace.selected_spans[4].start as usize..trace.selected_spans[4].end as usize], "0.75");
} }
// --- arp tests ---
fn get_notes(outputs: &[String]) -> Vec<f64> {
outputs
.iter()
.map(|o| parse_params(o).get("note").copied().unwrap_or(0.0))
.collect()
}
fn get_gains(outputs: &[String]) -> Vec<f64> {
outputs
.iter()
.map(|o| parse_params(o).get("gain").copied().unwrap_or(f64::NAN))
.collect()
}
#[test]
fn arp_auto_subdivide() {
let outputs = expect_outputs(r#"sine s c4 e4 g4 b4 arp note ."#, 4);
let notes = get_notes(&outputs);
assert!(approx_eq(notes[0], 60.0));
assert!(approx_eq(notes[1], 64.0));
assert!(approx_eq(notes[2], 67.0));
assert!(approx_eq(notes[3], 71.0));
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
assert!(approx_eq(deltas[0], 0.0));
assert!(approx_eq(deltas[1], 0.25 * step_dur));
assert!(approx_eq(deltas[2], 0.5 * step_dur));
assert!(approx_eq(deltas[3], 0.75 * step_dur));
}
#[test]
fn arp_with_explicit_at() {
let outputs = expect_outputs(r#"0 0.25 0.5 0.75 at sine s c4 e4 g4 b4 arp note ."#, 4);
let notes = get_notes(&outputs);
assert!(approx_eq(notes[0], 60.0));
assert!(approx_eq(notes[1], 64.0));
assert!(approx_eq(notes[2], 67.0));
assert!(approx_eq(notes[3], 71.0));
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
assert!(approx_eq(deltas[0], 0.0));
assert!(approx_eq(deltas[1], 0.25 * step_dur));
assert!(approx_eq(deltas[2], 0.5 * step_dur));
assert!(approx_eq(deltas[3], 0.75 * step_dur));
}
#[test]
fn arp_single_note() {
let outputs = expect_outputs(r#"sine s c4 arp note ."#, 1);
let notes = get_notes(&outputs);
assert!(approx_eq(notes[0], 60.0));
}
#[test]
fn arp_fewer_deltas_than_notes() {
let outputs = expect_outputs(r#"0 0.5 at sine s c4 e4 g4 b4 arp note ."#, 4);
let notes = get_notes(&outputs);
assert!(approx_eq(notes[0], 60.0));
assert!(approx_eq(notes[1], 64.0));
assert!(approx_eq(notes[2], 67.0));
assert!(approx_eq(notes[3], 71.0));
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
assert!(approx_eq(deltas[0], 0.0));
assert!(approx_eq(deltas[1], 0.5 * step_dur));
assert!(approx_eq(deltas[2], 0.0)); // wraps: 2 % 2 = 0
assert!(approx_eq(deltas[3], 0.5 * step_dur)); // wraps: 3 % 2 = 1
}
#[test]
fn arp_fewer_notes_than_deltas() {
let outputs = expect_outputs(r#"0 0.25 0.5 0.75 at sine s c4 e4 arp note ."#, 4);
let notes = get_notes(&outputs);
assert!(approx_eq(notes[0], 60.0));
assert!(approx_eq(notes[1], 64.0));
assert!(approx_eq(notes[2], 60.0)); // wraps
assert!(approx_eq(notes[3], 64.0)); // wraps
}
#[test]
fn arp_multiple_params() {
let outputs = expect_outputs(r#"sine s c4 e4 g4 arp note 0.5 0.7 0.9 arp gain ."#, 3);
let notes = get_notes(&outputs);
assert!(approx_eq(notes[0], 60.0));
assert!(approx_eq(notes[1], 64.0));
assert!(approx_eq(notes[2], 67.0));
let gains = get_gains(&outputs);
assert!(approx_eq(gains[0], 0.5));
assert!(approx_eq(gains[1], 0.7));
assert!(approx_eq(gains[2], 0.9));
}
#[test]
fn arp_no_arp_unchanged() {
// Standard CycleList without arp → cross-product (backward compat)
let outputs = expect_outputs(r#"0 0.5 at sine s c4 e4 note ."#, 4);
let notes = get_notes(&outputs);
// Cross-product: each note at each delta
assert!(approx_eq(notes[0], 60.0));
assert!(approx_eq(notes[1], 60.0));
assert!(approx_eq(notes[2], 64.0));
assert!(approx_eq(notes[3], 64.0));
}
#[test]
fn arp_mixed_cycle_and_arp() {
// CycleList sound + ArpList note → flat loop, sound cycles
let outputs = expect_outputs(r#"sine saw s c4 e4 g4 arp note ."#, 3);
let sounds = get_sounds(&outputs);
// Sound is CycleList, cycles across the 3 arp emissions
assert_eq!(sounds[0], "sine");
assert_eq!(sounds[1], "saw");
assert_eq!(sounds[2], "sine");
let notes = get_notes(&outputs);
assert!(approx_eq(notes[0], 60.0));
assert!(approx_eq(notes[1], 64.0));
assert!(approx_eq(notes[2], 67.0));
}