Compare commits
9 Commits
bc5d12e53a
...
b2871ac251
| Author | SHA1 | Date | |
|---|---|---|---|
| b2871ac251 | |||
| 8ba89f91a0 | |||
| 7d670dacb9 | |||
| 1de8c068f6 | |||
| d792f011ee | |||
| 897f1a776e | |||
| 869d3af244 | |||
| a5f17687f1 | |||
| 5b851751e5 |
10
CHANGELOG.md
10
CHANGELOG.md
@@ -2,24 +2,22 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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
|
||||
## [0.0.9]
|
||||
|
||||
### Website
|
||||
- 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.
|
||||
|
||||
### 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.
|
||||
- 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
|
||||
- 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%+.
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.8"
|
||||
version = "0.0.9"
|
||||
edition = "2021"
|
||||
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
|
||||
license = "AGPL-3.0"
|
||||
|
||||
@@ -98,6 +98,7 @@ pub enum Op {
|
||||
ClearCmd,
|
||||
SetSpeed,
|
||||
At,
|
||||
Arp,
|
||||
IntRange,
|
||||
StepRange,
|
||||
Generate,
|
||||
|
||||
@@ -88,6 +88,7 @@ pub enum Value {
|
||||
Str(Arc<str>, Option<SourceSpan>),
|
||||
Quotation(Arc<[Op]>, Option<SourceSpan>),
|
||||
CycleList(Arc<[Value]>),
|
||||
ArpList(Arc<[Value]>),
|
||||
}
|
||||
|
||||
impl PartialEq for Value {
|
||||
@@ -98,6 +99,7 @@ impl PartialEq for Value {
|
||||
(Value::Str(a, _), Value::Str(b, _)) => a == b,
|
||||
(Value::Quotation(a, _), Value::Quotation(b, _)) => a == b,
|
||||
(Value::CycleList(a), Value::CycleList(b)) => a == b,
|
||||
(Value::ArpList(a), Value::ArpList(b)) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -133,7 +135,7 @@ impl Value {
|
||||
Value::Float(f, _) => *f != 0.0,
|
||||
Value::Str(s, _) => !s.is_empty(),
|
||||
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::Str(s, _) => s.to_string(),
|
||||
Value::Quotation(..) => String::new(),
|
||||
Value::CycleList(_) => String::new(),
|
||||
Value::CycleList(_) | Value::ArpList(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn span(&self) -> Option<SourceSpan> {
|
||||
match self {
|
||||
Value::Int(_, s) | Value::Float(_, s) | Value::Str(_, s) | Value::Quotation(_, s) => *s,
|
||||
Value::CycleList(_) => None,
|
||||
Value::CycleList(_) | Value::ArpList(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,9 +190,7 @@ impl Forth {
|
||||
outputs: &mut Vec<String>,
|
||||
cmd: &mut CmdRegister|
|
||||
-> Result<(), String> {
|
||||
if stack.len() < count {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
ensure(stack, count)?;
|
||||
let start = stack.len() - count;
|
||||
let selected = stack[start + idx].clone();
|
||||
stack.truncate(start);
|
||||
@@ -216,6 +214,28 @@ impl Forth {
|
||||
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,
|
||||
emit_idx: usize,
|
||||
delta_secs: f64,
|
||||
@@ -231,7 +251,7 @@ impl Forth {
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
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(trace) = trace_cell.borrow_mut().as_mut() {
|
||||
trace.selected_spans.push(span);
|
||||
@@ -258,106 +278,81 @@ impl Forth {
|
||||
Op::PushStr(s, span) => stack.push(Value::Str(s.clone(), *span)),
|
||||
|
||||
Op::Dup => {
|
||||
let v = stack.last().ok_or("stack underflow")?.clone();
|
||||
ensure(stack, 1)?;
|
||||
let v = stack.last().unwrap().clone();
|
||||
stack.push(v);
|
||||
}
|
||||
Op::Dupn => {
|
||||
let n = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let v = stack.pop().ok_or("stack underflow")?;
|
||||
let n = pop_int(stack)?;
|
||||
let v = pop(stack)?;
|
||||
for _ in 0..n {
|
||||
stack.push(v.clone());
|
||||
}
|
||||
}
|
||||
Op::Drop => {
|
||||
stack.pop().ok_or("stack underflow")?;
|
||||
pop(stack)?;
|
||||
}
|
||||
Op::Swap => {
|
||||
ensure(stack, 2)?;
|
||||
let len = stack.len();
|
||||
if len < 2 {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
stack.swap(len - 1, len - 2);
|
||||
}
|
||||
Op::Over => {
|
||||
let len = stack.len();
|
||||
if len < 2 {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let v = stack[len - 2].clone();
|
||||
ensure(stack, 2)?;
|
||||
let v = stack[stack.len() - 2].clone();
|
||||
stack.push(v);
|
||||
}
|
||||
Op::Rot => {
|
||||
let len = stack.len();
|
||||
if len < 3 {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let v = stack.remove(len - 3);
|
||||
ensure(stack, 3)?;
|
||||
let v = stack.remove(stack.len() - 3);
|
||||
stack.push(v);
|
||||
}
|
||||
Op::Nip => {
|
||||
let len = stack.len();
|
||||
if len < 2 {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
stack.remove(len - 2);
|
||||
ensure(stack, 2)?;
|
||||
stack.remove(stack.len() - 2);
|
||||
}
|
||||
Op::Tuck => {
|
||||
ensure(stack, 2)?;
|
||||
let len = stack.len();
|
||||
if len < 2 {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let v = stack[len - 1].clone();
|
||||
stack.insert(len - 2, v);
|
||||
}
|
||||
Op::Dup2 => {
|
||||
ensure(stack, 2)?;
|
||||
let len = stack.len();
|
||||
if len < 2 {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let a = stack[len - 2].clone();
|
||||
let b = stack[len - 1].clone();
|
||||
stack.push(a);
|
||||
stack.push(b);
|
||||
}
|
||||
Op::Drop2 => {
|
||||
let len = stack.len();
|
||||
if len < 2 {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
ensure(stack, 2)?;
|
||||
stack.pop();
|
||||
stack.pop();
|
||||
}
|
||||
Op::Swap2 => {
|
||||
ensure(stack, 4)?;
|
||||
let len = stack.len();
|
||||
if len < 4 {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
stack.swap(len - 4, len - 2);
|
||||
stack.swap(len - 3, len - 1);
|
||||
}
|
||||
Op::Over2 => {
|
||||
ensure(stack, 4)?;
|
||||
let len = stack.len();
|
||||
if len < 4 {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let a = stack[len - 4].clone();
|
||||
let b = stack[len - 3].clone();
|
||||
stack.push(a);
|
||||
stack.push(b);
|
||||
}
|
||||
Op::Rev => {
|
||||
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
|
||||
if count > stack.len() {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let count = pop_int(stack)? as usize;
|
||||
ensure(stack, count)?;
|
||||
let start = stack.len() - count;
|
||||
stack[start..].reverse();
|
||||
}
|
||||
Op::Shuffle => {
|
||||
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
|
||||
if count > stack.len() {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let count = pop_int(stack)? as usize;
|
||||
ensure(stack, count)?;
|
||||
let start = stack.len() - count;
|
||||
let slice = &mut stack[start..];
|
||||
let mut rng = self.rng.lock();
|
||||
@@ -367,10 +362,8 @@ impl Forth {
|
||||
}
|
||||
}
|
||||
Op::Sort => {
|
||||
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
|
||||
if count > stack.len() {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let count = pop_int(stack)? as usize;
|
||||
ensure(stack, count)?;
|
||||
let start = stack.len() - count;
|
||||
stack[start..].sort_by(|a, b| {
|
||||
a.as_float()
|
||||
@@ -380,10 +373,8 @@ impl Forth {
|
||||
});
|
||||
}
|
||||
Op::RSort => {
|
||||
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
|
||||
if count > stack.len() {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let count = pop_int(stack)? as usize;
|
||||
ensure(stack, count)?;
|
||||
let start = stack.len() - count;
|
||||
stack[start..].sort_by(|a, b| {
|
||||
b.as_float()
|
||||
@@ -393,10 +384,8 @@ impl Forth {
|
||||
});
|
||||
}
|
||||
Op::Sum => {
|
||||
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
|
||||
if count > stack.len() {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let count = pop_int(stack)? as usize;
|
||||
ensure(stack, count)?;
|
||||
let start = stack.len() - count;
|
||||
let total: f64 = stack
|
||||
.drain(start..)
|
||||
@@ -405,10 +394,8 @@ impl Forth {
|
||||
stack.push(float_to_value(total));
|
||||
}
|
||||
Op::Prod => {
|
||||
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
|
||||
if count > stack.len() {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let count = pop_int(stack)? as usize;
|
||||
ensure(stack, count)?;
|
||||
let start = stack.len() - count;
|
||||
let product: f64 = stack
|
||||
.drain(start..)
|
||||
@@ -421,16 +408,16 @@ impl Forth {
|
||||
Op::Sub => binary_op(stack, |a, b| a - b)?,
|
||||
Op::Mul => binary_op(stack, |a, b| a * b)?,
|
||||
Op::Div => {
|
||||
let b = stack.pop().ok_or("stack underflow")?;
|
||||
let a = stack.pop().ok_or("stack underflow")?;
|
||||
let b = pop(stack)?;
|
||||
let a = pop(stack)?;
|
||||
if b.as_float().map_or(true, |v| v == 0.0) {
|
||||
return Err("division by zero".into());
|
||||
}
|
||||
stack.push(lift_binary(a, b, |x, y| x / y)?);
|
||||
}
|
||||
Op::Mod => {
|
||||
let b = stack.pop().ok_or("stack underflow")?;
|
||||
let a = stack.pop().ok_or("stack underflow")?;
|
||||
let b = pop(stack)?;
|
||||
let a = pop(stack)?;
|
||||
if b.as_float().map_or(true, |v| v == 0.0) {
|
||||
return Err("modulo by zero".into());
|
||||
}
|
||||
@@ -438,42 +425,42 @@ impl Forth {
|
||||
stack.push(result);
|
||||
}
|
||||
Op::Neg => {
|
||||
let v = stack.pop().ok_or("stack underflow")?;
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| -x)?);
|
||||
}
|
||||
Op::Abs => {
|
||||
let v = stack.pop().ok_or("stack underflow")?;
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| x.abs())?);
|
||||
}
|
||||
Op::Floor => {
|
||||
let v = stack.pop().ok_or("stack underflow")?;
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| x.floor())?);
|
||||
}
|
||||
Op::Ceil => {
|
||||
let v = stack.pop().ok_or("stack underflow")?;
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| x.ceil())?);
|
||||
}
|
||||
Op::Round => {
|
||||
let v = stack.pop().ok_or("stack underflow")?;
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| x.round())?);
|
||||
}
|
||||
Op::Min => binary_op(stack, |a, b| a.min(b))?,
|
||||
Op::Max => binary_op(stack, |a, b| a.max(b))?,
|
||||
Op::Pow => binary_op(stack, |a, b| a.powf(b))?,
|
||||
Op::Sqrt => {
|
||||
let v = stack.pop().ok_or("stack underflow")?;
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| x.sqrt())?);
|
||||
}
|
||||
Op::Sin => {
|
||||
let v = stack.pop().ok_or("stack underflow")?;
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| x.sin())?);
|
||||
}
|
||||
Op::Cos => {
|
||||
let v = stack.pop().ok_or("stack underflow")?;
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| x.cos())?);
|
||||
}
|
||||
Op::Log => {
|
||||
let v = stack.pop().ok_or("stack underflow")?;
|
||||
let v = pop(stack)?;
|
||||
stack.push(lift_unary(v, |x| x.ln())?);
|
||||
}
|
||||
|
||||
@@ -485,37 +472,37 @@ impl Forth {
|
||||
Op::Ge => cmp_op(stack, |a, b| a >= b)?,
|
||||
|
||||
Op::And => {
|
||||
let b = stack.pop().ok_or("stack underflow")?.is_truthy();
|
||||
let a = stack.pop().ok_or("stack underflow")?.is_truthy();
|
||||
let b = pop_bool(stack)?;
|
||||
let a = pop_bool(stack)?;
|
||||
stack.push(Value::Int(if a && b { 1 } else { 0 }, None));
|
||||
}
|
||||
Op::Or => {
|
||||
let b = stack.pop().ok_or("stack underflow")?.is_truthy();
|
||||
let a = stack.pop().ok_or("stack underflow")?.is_truthy();
|
||||
let b = pop_bool(stack)?;
|
||||
let a = pop_bool(stack)?;
|
||||
stack.push(Value::Int(if a || b { 1 } else { 0 }, None));
|
||||
}
|
||||
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));
|
||||
}
|
||||
Op::Xor => {
|
||||
let b = stack.pop().ok_or("stack underflow")?.is_truthy();
|
||||
let a = stack.pop().ok_or("stack underflow")?.is_truthy();
|
||||
let b = pop_bool(stack)?;
|
||||
let a = pop_bool(stack)?;
|
||||
stack.push(Value::Int(if a ^ b { 1 } else { 0 }, None));
|
||||
}
|
||||
Op::Nand => {
|
||||
let b = stack.pop().ok_or("stack underflow")?.is_truthy();
|
||||
let a = stack.pop().ok_or("stack underflow")?.is_truthy();
|
||||
let b = pop_bool(stack)?;
|
||||
let a = pop_bool(stack)?;
|
||||
stack.push(Value::Int(if !(a && b) { 1 } else { 0 }, None));
|
||||
}
|
||||
Op::Nor => {
|
||||
let b = stack.pop().ok_or("stack underflow")?.is_truthy();
|
||||
let a = stack.pop().ok_or("stack underflow")?.is_truthy();
|
||||
let b = pop_bool(stack)?;
|
||||
let a = pop_bool(stack)?;
|
||||
stack.push(Value::Int(if !(a || b) { 1 } else { 0 }, None));
|
||||
}
|
||||
|
||||
Op::BranchIfZero(offset, then_span, else_span) => {
|
||||
let v = stack.pop().ok_or("stack underflow")?;
|
||||
let v = pop(stack)?;
|
||||
if !v.is_truthy() {
|
||||
if let Some(span) = else_span {
|
||||
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
|
||||
@@ -534,9 +521,7 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::NewCmd => {
|
||||
if stack.is_empty() {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
ensure(stack, 1)?;
|
||||
let values = std::mem::take(stack);
|
||||
let val = if values.len() == 1 {
|
||||
values.into_iter().next().unwrap()
|
||||
@@ -546,9 +531,7 @@ impl Forth {
|
||||
cmd.set_sound(val);
|
||||
}
|
||||
Op::SetParam(param) => {
|
||||
if stack.is_empty() {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
ensure(stack, 1)?;
|
||||
let values = std::mem::take(stack);
|
||||
let val = if values.len() == 1 {
|
||||
values.into_iter().next().unwrap()
|
||||
@@ -559,24 +542,36 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::Emit => {
|
||||
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()
|
||||
};
|
||||
if has_arp_list(cmd) {
|
||||
let arp_count = compute_arp_count(cmd);
|
||||
let explicit_deltas = !cmd.deltas().is_empty();
|
||||
let delta_list: Vec<Value> = if explicit_deltas {
|
||||
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 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);
|
||||
for i in 0..count {
|
||||
let delta_secs = if explicit_deltas {
|
||||
let dv = &delta_list[i % delta_list.len()];
|
||||
let frac = dv.as_float()?;
|
||||
if let Some(span) = dv.span() {
|
||||
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
|
||||
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) =
|
||||
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(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 => {
|
||||
let name = stack.pop().ok_or("stack underflow")?;
|
||||
let name = pop(stack)?;
|
||||
let name = name.as_str()?;
|
||||
let vw = var_writes_cell.borrow();
|
||||
let vw_ref = vw.as_ref().expect("var_writes taken");
|
||||
@@ -602,9 +628,9 @@ impl Forth {
|
||||
stack.push(val);
|
||||
}
|
||||
Op::Set => {
|
||||
let name = stack.pop().ok_or("stack underflow")?;
|
||||
let name = pop(stack)?;
|
||||
let name = name.as_str()?.to_string();
|
||||
let val = stack.pop().ok_or("stack underflow")?;
|
||||
let val = pop(stack)?;
|
||||
var_writes_cell
|
||||
.borrow_mut()
|
||||
.as_mut()
|
||||
@@ -638,8 +664,8 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::Rand(word_span) => {
|
||||
let b = stack.pop().ok_or("stack underflow")?;
|
||||
let a = stack.pop().ok_or("stack underflow")?;
|
||||
let b = pop(stack)?;
|
||||
let a = pop(stack)?;
|
||||
match (&a, &b) {
|
||||
(Value::Int(a_i, _), Value::Int(b_i, _)) => {
|
||||
let (lo, hi) = if a_i <= b_i {
|
||||
@@ -666,8 +692,8 @@ impl Forth {
|
||||
}
|
||||
}
|
||||
Op::ExpRand(word_span) => {
|
||||
let hi = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let lo = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let hi = pop_float(stack)?;
|
||||
let lo = pop_float(stack)?;
|
||||
if lo <= 0.0 || hi <= 0.0 {
|
||||
return Err("exprand requires positive values".into());
|
||||
}
|
||||
@@ -678,8 +704,8 @@ impl Forth {
|
||||
stack.push(Value::Float(val, None));
|
||||
}
|
||||
Op::LogRand(word_span) => {
|
||||
let hi = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let lo = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let hi = pop_float(stack)?;
|
||||
let lo = pop_float(stack)?;
|
||||
if lo <= 0.0 || hi <= 0.0 {
|
||||
return Err("logrand requires positive values".into());
|
||||
}
|
||||
@@ -690,12 +716,12 @@ impl Forth {
|
||||
stack.push(Value::Float(val, None));
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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 {
|
||||
return Err("cycle count must be > 0".into());
|
||||
}
|
||||
@@ -714,7 +740,7 @@ impl Forth {
|
||||
}
|
||||
|
||||
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 {
|
||||
return Err("choose count must be > 0".into());
|
||||
}
|
||||
@@ -730,7 +756,7 @@ impl Forth {
|
||||
}
|
||||
|
||||
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 {
|
||||
return Err("bounce count must be > 0".into());
|
||||
}
|
||||
@@ -752,14 +778,12 @@ impl Forth {
|
||||
}
|
||||
|
||||
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 {
|
||||
return Err("wchoose count must be > 0".into());
|
||||
}
|
||||
let pairs_needed = count * 2;
|
||||
if stack.len() < pairs_needed {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
ensure(stack, pairs_needed)?;
|
||||
let start = stack.len() - pairs_needed;
|
||||
let mut values = 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) => {
|
||||
let threshold = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let quot = stack.pop().ok_or("stack underflow")?;
|
||||
let threshold = pop_float(stack)?;
|
||||
let quot = pop(stack)?;
|
||||
let val: f64 = self.rng.lock().gen();
|
||||
let limit = match &ops[pc] {
|
||||
Op::ChanceExec(_) => threshold,
|
||||
@@ -815,7 +839,7 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::Every => {
|
||||
let n = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let n = pop_int(stack)?;
|
||||
if n <= 0 {
|
||||
return Err("every count must be > 0".into());
|
||||
}
|
||||
@@ -828,8 +852,8 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::When | Op::Unless => {
|
||||
let cond = stack.pop().ok_or("stack underflow")?;
|
||||
let quot = stack.pop().ok_or("stack underflow")?;
|
||||
let cond = pop(stack)?;
|
||||
let quot = pop(stack)?;
|
||||
let should_run = match &ops[pc] {
|
||||
Op::When => cond.is_truthy(),
|
||||
_ => !cond.is_truthy(),
|
||||
@@ -840,9 +864,9 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::IfElse => {
|
||||
let cond = stack.pop().ok_or("stack underflow")?;
|
||||
let false_quot = stack.pop().ok_or("stack underflow")?;
|
||||
let true_quot = stack.pop().ok_or("stack underflow")?;
|
||||
let cond = pop(stack)?;
|
||||
let false_quot = pop(stack)?;
|
||||
let true_quot = pop(stack)?;
|
||||
let quot = if cond.is_truthy() {
|
||||
true_quot
|
||||
} else {
|
||||
@@ -852,7 +876,7 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::Pick => {
|
||||
let idx_i = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let idx_i = pop_int(stack)?;
|
||||
if idx_i < 0 {
|
||||
return Err(format!("pick index must be >= 0, got {idx_i}"));
|
||||
}
|
||||
@@ -879,13 +903,13 @@ impl Forth {
|
||||
}
|
||||
|
||||
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);
|
||||
stack.push(Value::Float(freq, None));
|
||||
}
|
||||
|
||||
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();
|
||||
stack.push(Value::Float(note, None));
|
||||
}
|
||||
@@ -894,7 +918,7 @@ impl Forth {
|
||||
if pattern.is_empty() {
|
||||
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 result = lift_unary_int(val, |degree| {
|
||||
let octave_offset = degree.div_euclid(len);
|
||||
@@ -905,21 +929,21 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::Chord(intervals) => {
|
||||
let root = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let root = pop_int(stack)?;
|
||||
for &interval in *intervals {
|
||||
stack.push(Value::Int(root + interval, None));
|
||||
}
|
||||
}
|
||||
|
||||
Op::Oct => {
|
||||
let shift = stack.pop().ok_or("stack underflow")?;
|
||||
let note = stack.pop().ok_or("stack underflow")?;
|
||||
let shift = pop(stack)?;
|
||||
let note = pop(stack)?;
|
||||
let result = lift_binary(note, shift, |n, s| n + s * 12.0)?;
|
||||
stack.push(result);
|
||||
}
|
||||
|
||||
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);
|
||||
var_writes_cell
|
||||
.borrow_mut()
|
||||
@@ -929,7 +953,7 @@ impl Forth {
|
||||
}
|
||||
|
||||
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);
|
||||
var_writes_cell
|
||||
.borrow_mut()
|
||||
@@ -939,8 +963,8 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::Chain => {
|
||||
let pattern = stack.pop().ok_or("stack underflow")?.as_int()? - 1;
|
||||
let bank = stack.pop().ok_or("stack underflow")?.as_int()? - 1;
|
||||
let pattern = pop_int(stack)? - 1;
|
||||
let bank = pop_int(stack)? - 1;
|
||||
if bank < 0 || pattern < 0 {
|
||||
return Err("chain: bank and pattern must be >= 1".into());
|
||||
}
|
||||
@@ -959,7 +983,7 @@ impl Forth {
|
||||
}
|
||||
|
||||
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 {
|
||||
return Err("tempo and speed must be non-zero".into());
|
||||
}
|
||||
@@ -969,18 +993,22 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::At => {
|
||||
if stack.is_empty() {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
ensure(stack, 1)?;
|
||||
let deltas = std::mem::take(stack);
|
||||
cmd.set_deltas(deltas);
|
||||
}
|
||||
|
||||
Op::Arp => {
|
||||
ensure(stack, 1)?;
|
||||
let values = std::mem::take(stack);
|
||||
stack.push(Value::ArpList(Arc::from(values)));
|
||||
}
|
||||
|
||||
Op::Adsr => {
|
||||
let r = stack.pop().ok_or("stack underflow")?;
|
||||
let s = stack.pop().ok_or("stack underflow")?;
|
||||
let d = stack.pop().ok_or("stack underflow")?;
|
||||
let a = stack.pop().ok_or("stack underflow")?;
|
||||
let r = pop(stack)?;
|
||||
let s = pop(stack)?;
|
||||
let d = pop(stack)?;
|
||||
let a = pop(stack)?;
|
||||
cmd.set_param("attack", a);
|
||||
cmd.set_param("decay", d);
|
||||
cmd.set_param("sustain", s);
|
||||
@@ -988,41 +1016,41 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::Ad => {
|
||||
let d = stack.pop().ok_or("stack underflow")?;
|
||||
let a = stack.pop().ok_or("stack underflow")?;
|
||||
let d = pop(stack)?;
|
||||
let a = pop(stack)?;
|
||||
cmd.set_param("attack", a);
|
||||
cmd.set_param("decay", d);
|
||||
cmd.set_param("sustain", Value::Int(0, None));
|
||||
}
|
||||
|
||||
Op::Apply => {
|
||||
let quot = stack.pop().ok_or("stack underflow")?;
|
||||
let quot = pop(stack)?;
|
||||
run_quotation(quot, stack, outputs, cmd)?;
|
||||
}
|
||||
|
||||
Op::Ramp => {
|
||||
let curve = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let freq = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let curve = pop_float(stack)?;
|
||||
let freq = pop_float(stack)?;
|
||||
let phase = (freq * ctx.beat).fract();
|
||||
let phase = if phase < 0.0 { phase + 1.0 } else { phase };
|
||||
let val = phase.powf(curve);
|
||||
stack.push(Value::Float(val, None));
|
||||
}
|
||||
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 = if phase < 0.0 { phase + 1.0 } else { phase };
|
||||
let val = 1.0 - (2.0 * phase - 1.0).abs();
|
||||
stack.push(Value::Float(val, None));
|
||||
}
|
||||
Op::Range => {
|
||||
let max = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let min = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let val = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let max = pop_float(stack)?;
|
||||
let min = pop_float(stack)?;
|
||||
let val = pop_float(stack)?;
|
||||
stack.push(Value::Float(min + val * (max - min), None));
|
||||
}
|
||||
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);
|
||||
stack.push(Value::Float(val, None));
|
||||
}
|
||||
@@ -1032,8 +1060,8 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::IntRange => {
|
||||
let end = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let start = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let end = pop_int(stack)?;
|
||||
let start = pop_int(stack)?;
|
||||
if start <= end {
|
||||
for i in start..=end {
|
||||
stack.push(Value::Int(i, None));
|
||||
@@ -1046,9 +1074,9 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::StepRange => {
|
||||
let step = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let end = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let start = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let step = pop_float(stack)?;
|
||||
let end = pop_float(stack)?;
|
||||
let start = pop_float(stack)?;
|
||||
if step == 0.0 {
|
||||
return Err("step cannot be zero".into());
|
||||
}
|
||||
@@ -1065,8 +1093,8 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::Generate => {
|
||||
let count = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let quot = stack.pop().ok_or("stack underflow")?;
|
||||
let count = pop_int(stack)?;
|
||||
let quot = pop(stack)?;
|
||||
if count < 0 {
|
||||
return Err("gen count must be >= 0".into());
|
||||
}
|
||||
@@ -1081,8 +1109,8 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::Times => {
|
||||
let quot = stack.pop().ok_or("stack underflow")?;
|
||||
let count = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let quot = pop(stack)?;
|
||||
let count = pop_int(stack)?;
|
||||
if count < 0 {
|
||||
return Err("times count must be >= 0".into());
|
||||
}
|
||||
@@ -1097,9 +1125,9 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::GeomRange => {
|
||||
let count = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let ratio = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let start = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let count = pop_int(stack)?;
|
||||
let ratio = pop_float(stack)?;
|
||||
let start = pop_float(stack)?;
|
||||
if count < 0 {
|
||||
return Err("geom.. count must be >= 0".into());
|
||||
}
|
||||
@@ -1111,8 +1139,8 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::Euclid => {
|
||||
let n = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let k = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let n = pop_int(stack)?;
|
||||
let k = pop_int(stack)?;
|
||||
if k < 0 || n < 0 {
|
||||
return Err("euclid: k and n must be >= 0".into());
|
||||
}
|
||||
@@ -1122,9 +1150,9 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::EuclidRot => {
|
||||
let r = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let n = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let k = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let r = pop_int(stack)?;
|
||||
let n = pop_int(stack)?;
|
||||
let k = pop_int(stack)?;
|
||||
if k < 0 || n < 0 || r < 0 {
|
||||
return Err("euclidrot: k, n, and r must be >= 0".into());
|
||||
}
|
||||
@@ -1134,33 +1162,31 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::ModLfo(shape) => {
|
||||
let period = stack.pop().ok_or("stack underflow")?.as_float()? * ctx.step_duration();
|
||||
let max = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let min = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let period = pop_float(stack)? * ctx.step_duration();
|
||||
let max = pop_float(stack)?;
|
||||
let min = pop_float(stack)?;
|
||||
let suffix = match shape { 1 => "t", 2 => "w", 3 => "q", _ => "" };
|
||||
let s = format!("{min}~{max}:{period}{suffix}");
|
||||
stack.push(Value::Str(s.into(), None));
|
||||
}
|
||||
Op::ModSlide(curve) => {
|
||||
let dur = stack.pop().ok_or("stack underflow")?.as_float()? * ctx.step_duration();
|
||||
let end = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let start = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let dur = pop_float(stack)? * ctx.step_duration();
|
||||
let end = pop_float(stack)?;
|
||||
let start = pop_float(stack)?;
|
||||
let suffix = match curve { 1 => "e", 2 => "s", _ => "" };
|
||||
let s = format!("{start}>{end}:{dur}{suffix}");
|
||||
stack.push(Value::Str(s.into(), None));
|
||||
}
|
||||
Op::ModRnd(dist) => {
|
||||
let period = stack.pop().ok_or("stack underflow")?.as_float()? * ctx.step_duration();
|
||||
let max = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let min = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
let period = pop_float(stack)? * ctx.step_duration();
|
||||
let max = pop_float(stack)?;
|
||||
let min = pop_float(stack)?;
|
||||
let suffix = match dist { 1 => "s", 2 => "d", _ => "" };
|
||||
let s = format!("{min}?{max}:{period}{suffix}");
|
||||
stack.push(Value::Str(s.into(), None));
|
||||
}
|
||||
Op::ModEnv => {
|
||||
if stack.is_empty() {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
ensure(stack, 1)?;
|
||||
let values = std::mem::take(stack);
|
||||
let mut floats = Vec::with_capacity(values.len());
|
||||
for v in &values {
|
||||
@@ -1246,8 +1272,8 @@ impl Forth {
|
||||
outputs.push(format!("/midi/continue/dev/{dev}"));
|
||||
}
|
||||
Op::GetMidiCC => {
|
||||
let chan = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let cc = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let chan = pop_int(stack)?;
|
||||
let cc = pop_int(stack)?;
|
||||
let cc_clamped = (cc.clamp(0, 127)) as usize;
|
||||
let chan_clamped = (chan.clamp(1, 16) - 1) as usize;
|
||||
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
|
||||
@@ -1260,8 +1286,8 @@ impl Forth {
|
||||
stack.push(Value::Int(val as i64, None));
|
||||
}
|
||||
Op::Forget => {
|
||||
let name = stack.pop().ok_or("stack underflow")?.as_str()?.to_string();
|
||||
self.dict.lock().remove(&name);
|
||||
let name = pop(stack)?;
|
||||
self.dict.lock().remove(name.as_str()?);
|
||||
}
|
||||
}
|
||||
pc += 1;
|
||||
@@ -1333,8 +1359,6 @@ fn is_tempo_scaled_param(name: &str) -> bool {
|
||||
| "fmd"
|
||||
| "fmr"
|
||||
| "glide"
|
||||
| "verbdecay"
|
||||
| "verbpredelay"
|
||||
| "chorusdelay"
|
||||
| "duration"
|
||||
)
|
||||
@@ -1472,6 +1496,29 @@ fn perlin_noise_1d(x: f64) -> f64 {
|
||||
(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 {
|
||||
if result.fract() == 0.0 && result.abs() < i64::MAX as f64 {
|
||||
Value::Int(result as i64, None)
|
||||
@@ -1505,8 +1552,8 @@ fn binary_op<F>(stack: &mut Vec<Value>, f: F) -> Result<(), String>
|
||||
where
|
||||
F: Fn(f64, f64) -> f64 + Copy,
|
||||
{
|
||||
let b = stack.pop().ok_or("stack underflow")?;
|
||||
let a = stack.pop().ok_or("stack underflow")?;
|
||||
let b = pop(stack)?;
|
||||
let a = pop(stack)?;
|
||||
stack.push(lift_binary(a, b, f)?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1515,8 +1562,8 @@ fn cmp_op<F>(stack: &mut Vec<Value>, f: F) -> Result<(), String>
|
||||
where
|
||||
F: Fn(f64, f64) -> bool,
|
||||
{
|
||||
let b = stack.pop().ok_or("stack underflow")?;
|
||||
let a = stack.pop().ok_or("stack underflow")?;
|
||||
let b = pop(stack)?;
|
||||
let a = pop(stack)?;
|
||||
let result = if f(a.as_float()?, b.as_float()?) {
|
||||
1
|
||||
} else {
|
||||
@@ -1528,7 +1575,7 @@ where
|
||||
|
||||
fn resolve_cycling(val: &Value, emit_idx: usize) -> Cow<'_, Value> {
|
||||
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())
|
||||
}
|
||||
other => Cow::Borrowed(other),
|
||||
|
||||
@@ -79,6 +79,7 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
|
||||
"tempo!" => Op::SetTempo,
|
||||
"speed!" => Op::SetSpeed,
|
||||
"at" => Op::At,
|
||||
"arp" => Op::Arp,
|
||||
"adsr" => Op::Adsr,
|
||||
"ad" => Op::Ad,
|
||||
"apply" => Op::Apply,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,61 +6,61 @@ pub(super) const WORDS: &[Word] = &[
|
||||
name: "chan",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set MIDI channel 1-16",
|
||||
example: "1 chan",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "ccnum",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set MIDI CC number 0-127",
|
||||
example: "1 ccnum",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "ccout",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set MIDI CC output value 0-127",
|
||||
example: "64 ccout",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "bend",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set pitch bend -1.0 to 1.0 (0 = center)",
|
||||
example: "0.5 bend",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "pressure",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set channel pressure (aftertouch) 0-127",
|
||||
example: "64 pressure",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "program",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set program change number 0-127",
|
||||
example: "0 program",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "m.",
|
||||
@@ -126,10 +126,10 @@ pub(super) const WORDS: &[Word] = &[
|
||||
name: "dev",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set MIDI device slot 0-3 for output/input",
|
||||
example: "1 dev 60 note m.",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -23,6 +23,16 @@ pub(super) const WORDS: &[Word] = &[
|
||||
compile: Simple,
|
||||
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 {
|
||||
name: "clear",
|
||||
aliases: &[],
|
||||
@@ -38,555 +48,555 @@ pub(super) const WORDS: &[Word] = &[
|
||||
name: "bank",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(str --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set sample bank suffix",
|
||||
example: "\"a\" bank",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "time",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set time offset",
|
||||
example: "0.1 time",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "repeat",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set repeat count",
|
||||
example: "4 repeat",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "dur",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set duration",
|
||||
example: "0.5 dur",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "gate",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set gate time",
|
||||
example: "0.8 gate",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "speed",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set playback speed",
|
||||
example: "1.5 speed",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "begin",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set sample start (0-1)",
|
||||
example: "0.25 begin",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "end",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set sample end (0-1)",
|
||||
example: "0.75 end",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "voice",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set voice number",
|
||||
example: "1 voice",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "orbit",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set orbit/bus",
|
||||
example: "0 orbit",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "n",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set sample number",
|
||||
example: "0 n",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "cut",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set cut group",
|
||||
example: "1 cut",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "reset",
|
||||
aliases: &[],
|
||||
category: "Sample",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Reset parameter",
|
||||
example: "1 reset",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
// Oscillator
|
||||
Word {
|
||||
name: "freq",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set frequency (Hz)",
|
||||
example: "440 freq",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "detune",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set detune amount",
|
||||
example: "0.01 detune",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "glide",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set glide/portamento",
|
||||
example: "0.1 glide",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "pw",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set pulse width",
|
||||
example: "0.5 pw",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "spread",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set stereo spread",
|
||||
example: "0.5 spread",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "mult",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set multiplier",
|
||||
example: "2 mult",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "warp",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set warp amount",
|
||||
example: "0.5 warp",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "mirror",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set mirror",
|
||||
example: "1 mirror",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "harmonics",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set harmonics (mutable only)",
|
||||
example: "4 harmonics",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "timbre",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set timbre (mutable only)",
|
||||
example: "0.5 timbre",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "morph",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set morph (mutable only)",
|
||||
example: "0.5 morph",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "coarse",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set coarse tune",
|
||||
example: "12 coarse",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "sub",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set sub oscillator level",
|
||||
example: "0.5 sub",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "suboct",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set sub oscillator octave",
|
||||
example: "2 suboct",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "subwave",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set sub oscillator waveform",
|
||||
example: "1 subwave",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "note",
|
||||
aliases: &[],
|
||||
category: "Oscillator",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set MIDI note",
|
||||
example: "60 note",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
// Wavetable
|
||||
Word {
|
||||
name: "scan",
|
||||
aliases: &[],
|
||||
category: "Wavetable",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set wavetable scan position (0-1)",
|
||||
example: "0.5 scan",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "wtlen",
|
||||
aliases: &[],
|
||||
category: "Wavetable",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set wavetable cycle length in samples",
|
||||
example: "2048 wtlen",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "scanlfo",
|
||||
aliases: &[],
|
||||
category: "Wavetable",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set scan LFO rate (Hz)",
|
||||
example: "0.2 scanlfo",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "scandepth",
|
||||
aliases: &[],
|
||||
category: "Wavetable",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set scan LFO depth (0-1)",
|
||||
example: "0.4 scandepth",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "scanshape",
|
||||
aliases: &[],
|
||||
category: "Wavetable",
|
||||
stack: "(s --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set scan LFO shape (sine/tri/saw/square/sh)",
|
||||
example: "\"tri\" scanshape",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
// FM
|
||||
Word {
|
||||
name: "fm",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM frequency",
|
||||
example: "200 fm",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fmh",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM harmonic ratio",
|
||||
example: "2 fmh",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fmshape",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM shape",
|
||||
example: "0 fmshape",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fme",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM envelope",
|
||||
example: "0.5 fme",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fma",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM attack",
|
||||
example: "0.01 fma",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fmd",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM decay",
|
||||
example: "0.1 fmd",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fms",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM sustain",
|
||||
example: "0.5 fms",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fmr",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM release",
|
||||
example: "0.1 fmr",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fm2",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM operator 2 depth",
|
||||
example: "1.5 fm2",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fm2h",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM operator 2 harmonic ratio",
|
||||
example: "3 fm2h",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fmalgo",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(n --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM algorithm (0=cascade 1=parallel 2=branch)",
|
||||
example: "0 fmalgo",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "fmfb",
|
||||
aliases: &[],
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set FM feedback amount",
|
||||
example: "0.5 fmfb",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
// Modulation
|
||||
Word {
|
||||
name: "vib",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set vibrato rate",
|
||||
example: "5 vib",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "vibmod",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set vibrato depth",
|
||||
example: "0.5 vibmod",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "vibshape",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set vibrato shape",
|
||||
example: "0 vibshape",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "am",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set AM frequency",
|
||||
example: "10 am",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "amdepth",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set AM depth",
|
||||
example: "0.5 amdepth",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "amshape",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set AM shape",
|
||||
example: "0 amshape",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "rm",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set RM frequency",
|
||||
example: "100 rm",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "rmdepth",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set RM depth",
|
||||
example: "0.5 rmdepth",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "rmshape",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
stack: "(f --)",
|
||||
stack: "(v.. --)",
|
||||
desc: "Set RM shape",
|
||||
example: "0 rmshape",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
varargs: true,
|
||||
},
|
||||
// LFO
|
||||
Word {
|
||||
|
||||
145
crates/ratatui/src/category_list.rs
Normal file
145
crates/ratatui/src/category_list.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
27
crates/ratatui/src/hint_bar.rs
Normal file
27
crates/ratatui/src/hint_bar.rs
Normal 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)
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
mod active_patterns;
|
||||
mod category_list;
|
||||
mod confirm;
|
||||
mod editor;
|
||||
mod file_browser;
|
||||
mod hint_bar;
|
||||
mod list_select;
|
||||
mod modal;
|
||||
mod nav_minimap;
|
||||
mod props_form;
|
||||
mod sample_browser;
|
||||
mod scope;
|
||||
mod scroll_indicators;
|
||||
mod search_bar;
|
||||
mod section_header;
|
||||
mod sparkles;
|
||||
mod spectrum;
|
||||
mod text_input;
|
||||
@@ -15,14 +21,20 @@ mod vu_meter;
|
||||
mod waveform;
|
||||
|
||||
pub use active_patterns::{ActivePatterns, MuteStatus};
|
||||
pub use category_list::{CategoryItem, CategoryList};
|
||||
pub use confirm::ConfirmModal;
|
||||
pub use editor::{fuzzy_match, CompletionCandidate, Editor};
|
||||
pub use file_browser::FileBrowserModal;
|
||||
pub use hint_bar::hint_line;
|
||||
pub use list_select::ListSelect;
|
||||
pub use modal::ModalFrame;
|
||||
pub use nav_minimap::{NavMinimap, NavTile};
|
||||
pub use props_form::render_props_form;
|
||||
pub use sample_browser::{SampleBrowser, TreeLine, TreeLineKind};
|
||||
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 spectrum::Spectrum;
|
||||
pub use text_input::TextInputModal;
|
||||
|
||||
42
crates/ratatui/src/props_form.rs
Normal file
42
crates/ratatui/src/props_form.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
53
crates/ratatui/src/scroll_indicators.rs
Normal file
53
crates/ratatui/src/scroll_indicators.rs
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
crates/ratatui/src/search_bar.rs
Normal file
20
crates/ratatui/src/search_bar.rs
Normal 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);
|
||||
}
|
||||
30
crates/ratatui/src/section_header.rs
Normal file
30
crates/ratatui/src/section_header.rs
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -24,19 +24,23 @@ impl Widget for Spectrum<'_> {
|
||||
|
||||
let colors = theme::get();
|
||||
let height = area.height as f32;
|
||||
let band_width = area.width as usize / 32;
|
||||
if band_width == 0 {
|
||||
let base = area.width as usize / 32;
|
||||
let remainder = area.width as usize % 32;
|
||||
if base == 0 && remainder == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut x_start = area.x;
|
||||
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 full_cells = bar_height as usize;
|
||||
let frac = bar_height - full_cells as f32;
|
||||
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 {
|
||||
let y = area.y + area.height - 1 - row as u16;
|
||||
let ratio = row as f32 / area.height as f32;
|
||||
@@ -47,11 +51,8 @@ impl Widget for Spectrum<'_> {
|
||||
} else {
|
||||
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;
|
||||
if x >= area.x + area.width {
|
||||
break;
|
||||
}
|
||||
if row < full_cells {
|
||||
buf[(x, y)].set_char(BLOCKS[7]).set_fg(color);
|
||||
} else if row == full_cells && frac_idx > 0 {
|
||||
@@ -59,6 +60,7 @@ impl Widget for Spectrum<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
x_start += w as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1183,6 +1183,7 @@ impl App {
|
||||
AppCommand::PageRight => self.page.right(),
|
||||
AppCommand::PageUp => self.page.up(),
|
||||
AppCommand::PageDown => self.page.down(),
|
||||
AppCommand::GoToPage(page) => self.page = page,
|
||||
|
||||
// Help navigation
|
||||
AppCommand::HelpToggleFocus => help_nav::toggle_focus(&mut self.ui),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
|
||||
use crate::page::Page;
|
||||
use crate::state::{ColorScheme, DeviceKind, Modal, PatternField, SettingKind};
|
||||
|
||||
pub enum AppCommand {
|
||||
@@ -142,6 +143,7 @@ pub enum AppCommand {
|
||||
PageRight,
|
||||
PageUp,
|
||||
PageDown,
|
||||
GoToPage(Page),
|
||||
|
||||
// Help navigation
|
||||
HelpToggleFocus,
|
||||
|
||||
@@ -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())
|
||||
});
|
||||
|
||||
let nyquist = sample_rate / 2.0;
|
||||
let min_freq: f32 = 20.0;
|
||||
let max_freq: f32 = 16000.0;
|
||||
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 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;
|
||||
|
||||
1968
src/input.rs
1968
src/input.rs
File diff suppressed because it is too large
Load Diff
189
src/input/engine_page.rs
Normal file
189
src/input/engine_page.rs
Normal 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
114
src/input/help_page.rs
Normal 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
248
src/input/main_page.rs
Normal 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
181
src/input/mod.rs
Normal 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, ®istry);
|
||||
})
|
||||
.expect("failed to spawn preload thread");
|
||||
}
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"Loaded {total_count} samples from project"
|
||||
)));
|
||||
}
|
||||
}
|
||||
553
src/input/modal.rs
Normal file
553
src/input/modal.rs
Normal 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, ®istry);
|
||||
})
|
||||
.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
178
src/input/options_page.rs
Normal 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
95
src/input/panel.rs
Normal 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
245
src/input/patterns_page.rs
Normal 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
|
||||
}
|
||||
@@ -98,7 +98,7 @@ fn format_value(v: &Value) -> String {
|
||||
}
|
||||
Value::Str(s, _) => format!("\"{s}\""),
|
||||
Value::Quotation(..) => "[...]".to_string(),
|
||||
Value::CycleList(items) => {
|
||||
Value::CycleList(items) | Value::ArpList(items) => {
|
||||
let inner: Vec<String> = items.iter().map(format_value).collect();
|
||||
format!("({})", inner.join(" "))
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ pub use editor::{
|
||||
PatternPropsField, StackCache,
|
||||
};
|
||||
pub use live_keys::LiveKeyState;
|
||||
pub use modal::Modal;
|
||||
pub use modal::{ConfirmAction, Modal, RenameTarget};
|
||||
pub use options::{OptionsFocus, OptionsState};
|
||||
pub use panel::{PanelFocus, PanelState, SidePanel};
|
||||
pub use patterns_nav::{PatternsColumn, PatternsNav};
|
||||
|
||||
@@ -2,56 +2,61 @@ use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
|
||||
use crate::state::editor::{EuclideanField, PatternField, PatternPropsField};
|
||||
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)]
|
||||
pub enum Modal {
|
||||
None,
|
||||
ConfirmQuit {
|
||||
selected: bool,
|
||||
},
|
||||
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>,
|
||||
Confirm {
|
||||
action: ConfirmAction,
|
||||
selected: bool,
|
||||
},
|
||||
FileBrowser(Box<FileBrowserState>),
|
||||
RenameBank {
|
||||
bank: usize,
|
||||
name: String,
|
||||
},
|
||||
RenamePattern {
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
name: String,
|
||||
},
|
||||
RenameStep {
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
step: usize,
|
||||
Rename {
|
||||
target: RenameTarget,
|
||||
name: String,
|
||||
},
|
||||
SetPattern {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
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 crate::app::App;
|
||||
@@ -9,6 +9,7 @@ use crate::model::categories::{get_category_name, CatEntry, CATEGORIES};
|
||||
use crate::model::{Word, WORDS};
|
||||
use crate::state::DictFocus;
|
||||
use crate::theme;
|
||||
use crate::widgets::{render_search_bar, CategoryItem, CategoryList};
|
||||
|
||||
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 focused = app.ui.dict_focus == DictFocus::Categories && !dimmed;
|
||||
|
||||
let visible_height = area.height.saturating_sub(2) as usize;
|
||||
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
|
||||
let items: Vec<CategoryItem> = CATEGORIES
|
||||
.iter()
|
||||
.take(scroll)
|
||||
.filter(|e| matches!(e, Category(_)))
|
||||
.count();
|
||||
|
||||
let items: Vec<ListItem> = CATEGORIES
|
||||
.iter()
|
||||
.skip(scroll)
|
||||
.take(visible_height)
|
||||
.map(|entry| match entry {
|
||||
Section(name) => {
|
||||
let style = Style::new().fg(theme.ui.text_dim);
|
||||
ListItem::new(format!("─ {name} ─")).style(style)
|
||||
}
|
||||
Category(name) => {
|
||||
let is_selected = cat_idx == app.ui.dict_category;
|
||||
let style = if dimmed {
|
||||
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)
|
||||
}
|
||||
Section(name) => CategoryItem {
|
||||
label: name,
|
||||
is_section: true,
|
||||
},
|
||||
Category(name) => CategoryItem {
|
||||
label: name,
|
||||
is_section: false,
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
let border_color = if focused { theme.dict.border_focused } else { theme.dict.border_normal };
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::new().fg(border_color))
|
||||
let mut list = CategoryList::new(&items, app.ui.dict_category)
|
||||
.focused(focused)
|
||||
.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) {
|
||||
let theme = theme::get();
|
||||
let focused = app.ui.dict_focus == DictFocus::Words;
|
||||
|
||||
// Filter words by search query or category
|
||||
let words: Vec<&Word> = if is_searching {
|
||||
let query = app.ui.dict_search_query.to_lowercase();
|
||||
WORDS
|
||||
@@ -142,7 +94,6 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) {
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Split area for search bar when search is active or has query
|
||||
let show_search = app.ui.dict_search_active || is_searching;
|
||||
let (search_area, content_area) = if show_search {
|
||||
let [s, c] =
|
||||
@@ -152,9 +103,8 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) {
|
||||
(None, area)
|
||||
};
|
||||
|
||||
// Render search bar
|
||||
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;
|
||||
@@ -229,17 +179,3 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) {
|
||||
.block(block);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ use ratatui::Frame;
|
||||
use crate::app::App;
|
||||
use crate::state::{DeviceKind, EngineSection, SettingKind};
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
// Scroll indicators
|
||||
let indicator_style = Style::new().fg(theme.engine.scroll_indicator);
|
||||
let indicator_x = padded.x + padded.width.saturating_sub(1);
|
||||
|
||||
if scroll_offset > 0 {
|
||||
let up_indicator = Paragraph::new("▲").style(indicator_style);
|
||||
frame.render_widget(up_indicator, Rect::new(indicator_x, padded.y, 1, 1));
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
render_scroll_indicators(
|
||||
frame,
|
||||
padded,
|
||||
scroll_offset,
|
||||
max_visible,
|
||||
total_lines,
|
||||
theme.engine.scroll_indicator,
|
||||
IndicatorAlign::Right,
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
let theme = theme::get();
|
||||
let section_focused = app.audio.section == EngineSection::Devices;
|
||||
|
||||
@@ -2,7 +2,7 @@ use cagire_markdown::{CodeHighlighter, MarkdownTheme};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
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;
|
||||
#[cfg(not(feature = "desktop"))]
|
||||
use tui_big_text::{BigText, PixelSize};
|
||||
@@ -12,6 +12,7 @@ use crate::model::docs::{get_topic, DocEntry, DOCS};
|
||||
use crate::state::HelpFocus;
|
||||
use crate::theme;
|
||||
use crate::views::highlight;
|
||||
use crate::widgets::{render_search_bar, CategoryItem, CategoryList};
|
||||
|
||||
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) {
|
||||
let theme = theme::get();
|
||||
let focused = app.ui.help_focus == HelpFocus::Topics;
|
||||
|
||||
let visible_height = area.height.saturating_sub(2) as usize;
|
||||
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
|
||||
let items: Vec<CategoryItem> = DOCS
|
||||
.iter()
|
||||
.take(scroll)
|
||||
.filter(|e| matches!(e, Topic(_, _)))
|
||||
.count();
|
||||
|
||||
let items: Vec<ListItem> = DOCS
|
||||
.iter()
|
||||
.skip(scroll)
|
||||
.take(visible_height)
|
||||
.map(|entry| match entry {
|
||||
Section(name) => {
|
||||
let style = Style::new().fg(theme.ui.text_dim);
|
||||
ListItem::new(format!("─ {name} ─")).style(style)
|
||||
}
|
||||
Topic(name, _) => {
|
||||
let selected = topic_idx == app.ui.help_topic;
|
||||
let style = if selected {
|
||||
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)
|
||||
}
|
||||
Section(name) => CategoryItem {
|
||||
label: name,
|
||||
is_section: true,
|
||||
},
|
||||
Topic(name, _) => CategoryItem {
|
||||
label: name,
|
||||
is_section: false,
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
let focused = app.ui.help_focus == HelpFocus::Topics;
|
||||
let border_color = if focused {
|
||||
theme.dict.border_focused
|
||||
} else {
|
||||
theme.dict.border_normal
|
||||
};
|
||||
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);
|
||||
CategoryList::new(&items, app.ui.help_topic)
|
||||
.focused(focused)
|
||||
.title("Topics")
|
||||
.selected_color(theme.dict.category_selected)
|
||||
.normal_color(theme.ui.text_primary)
|
||||
.render(frame, area);
|
||||
}
|
||||
|
||||
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, search] =
|
||||
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
|
||||
} else {
|
||||
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> {
|
||||
let theme = theme::get();
|
||||
let mut result: Vec<Span<'a>> = Vec::new();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::page::Page;
|
||||
|
||||
pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str)> {
|
||||
let mut bindings = Vec::new();
|
||||
|
||||
// Global bindings
|
||||
bindings.push(("Ctrl+←→↑↓", "Navigate", "Switch between views"));
|
||||
bindings.push(("q", "Quit", "Quit application"));
|
||||
bindings.push(("?", "Keybindings", "Show this help"));
|
||||
let mut bindings = vec![
|
||||
("F1–F6", "Go to view", "Dict/Patterns/Options/Help/Sequencer/Engine"),
|
||||
("Ctrl+←→↑↓", "Navigate", "Switch between adjacent views"),
|
||||
("q", "Quit", "Quit application"),
|
||||
("?", "Keybindings", "Show this help"),
|
||||
];
|
||||
|
||||
// Page-specific bindings
|
||||
match page {
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::engine::LinkState;
|
||||
use crate::midi;
|
||||
use crate::state::OptionsFocus;
|
||||
use crate::theme;
|
||||
use crate::widgets::{render_scroll_indicators, IndicatorAlign};
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
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);
|
||||
|
||||
let indicator_style = Style::new().fg(theme.ui.text_dim);
|
||||
let indicator_x = padded.x + padded.width.saturating_sub(1);
|
||||
|
||||
if scroll_offset > 0 {
|
||||
let up_indicator = Paragraph::new("▲").style(indicator_style);
|
||||
frame.render_widget(
|
||||
up_indicator,
|
||||
Rect::new(indicator_x, padded.y, 1, 1),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
render_scroll_indicators(
|
||||
frame,
|
||||
padded,
|
||||
scroll_offset,
|
||||
visible_end - scroll_offset,
|
||||
total_lines,
|
||||
theme.ui.text_dim,
|
||||
IndicatorAlign::Right,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_section_header(title: &str, theme: &theme::ThemeColors) -> Line<'static> {
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::engine::SequencerSnapshot;
|
||||
use crate::model::{MAX_BANKS, MAX_PATTERNS};
|
||||
use crate::state::PatternsColumn;
|
||||
use crate::theme;
|
||||
use crate::widgets::{render_scroll_indicators, IndicatorAlign};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Scroll indicators
|
||||
let indicator_style = Style::new().fg(theme.ui.text_muted);
|
||||
if scroll_offset > 0 {
|
||||
let indicator = Paragraph::new("▲")
|
||||
.style(indicator_style)
|
||||
.alignment(ratatui::layout::Alignment::Center);
|
||||
frame.render_widget(indicator, Rect { height: 1, ..inner });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
render_scroll_indicators(
|
||||
frame,
|
||||
inner,
|
||||
scroll_offset,
|
||||
visible_count,
|
||||
MAX_BANKS,
|
||||
theme.ui.text_muted,
|
||||
IndicatorAlign::Center,
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Scroll indicators
|
||||
let indicator_style = Style::new().fg(theme.ui.text_muted);
|
||||
if scroll_offset > 0 {
|
||||
let indicator = Paragraph::new("▲")
|
||||
.style(indicator_style)
|
||||
.alignment(ratatui::layout::Alignment::Center);
|
||||
frame.render_widget(indicator, Rect { height: 1, ..inner });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
render_scroll_indicators(
|
||||
frame,
|
||||
inner,
|
||||
scroll_offset,
|
||||
visible_count,
|
||||
MAX_PATTERNS,
|
||||
theme.ui.text_muted,
|
||||
IndicatorAlign::Center,
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
pub use cagire_ratatui::{
|
||||
ActivePatterns, ConfirmModal, FileBrowserModal, ModalFrame, MuteStatus, NavMinimap, NavTile,
|
||||
Orientation, SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter, Waveform,
|
||||
hint_line, render_props_form, render_scroll_indicators, render_search_bar,
|
||||
render_section_header, ActivePatterns, CategoryItem, CategoryList, ConfirmModal,
|
||||
FileBrowserModal, IndicatorAlign, ModalFrame, MuteStatus, NavMinimap, NavTile, Orientation,
|
||||
SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter, Waveform,
|
||||
};
|
||||
|
||||
@@ -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[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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user