From 8ba89f91a0b58f8f40192e0128107463c9e710d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Tue, 10 Feb 2026 23:51:17 +0100 Subject: [PATCH] Fixes --- crates/forth/src/vm.rs | 356 +++---- crates/ratatui/src/spectrum.rs | 18 +- src/input.rs | 1745 -------------------------------- src/input/engine_page.rs | 189 ++++ src/input/help_page.rs | 114 +++ src/input/main_page.rs | 248 +++++ src/input/mod.rs | 167 +++ src/input/modal.rs | 553 ++++++++++ src/input/options_page.rs | 178 ++++ src/input/panel.rs | 95 ++ src/input/patterns_page.rs | 245 +++++ src/views/render.rs | 684 +++++++------ 12 files changed, 2312 insertions(+), 2280 deletions(-) delete mode 100644 src/input.rs create mode 100644 src/input/engine_page.rs create mode 100644 src/input/help_page.rs create mode 100644 src/input/main_page.rs create mode 100644 src/input/mod.rs create mode 100644 src/input/modal.rs create mode 100644 src/input/options_page.rs create mode 100644 src/input/panel.rs create mode 100644 src/input/patterns_page.rs diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index 946e8d4..525775a 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -190,9 +190,7 @@ impl Forth { outputs: &mut Vec, 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); @@ -280,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(); @@ -389,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() @@ -402,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() @@ -415,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..) @@ -427,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..) @@ -443,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()); } @@ -460,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())?); } @@ -507,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() { @@ -556,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() @@ -568,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() @@ -654,7 +615,7 @@ impl Forth { } 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"); @@ -667,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() @@ -703,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 { @@ -731,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()); } @@ -743,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()); } @@ -755,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()); } @@ -779,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()); } @@ -795,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()); } @@ -817,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); @@ -858,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, @@ -880,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()); } @@ -893,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(), @@ -905,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 { @@ -917,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}")); } @@ -944,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)); } @@ -959,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); @@ -970,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() @@ -994,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() @@ -1004,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()); } @@ -1024,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()); } @@ -1034,26 +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 => { - if stack.is_empty() { - return Err("stack underflow".into()); - } + 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); @@ -1061,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)); } @@ -1105,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)); @@ -1119,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()); } @@ -1138,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()); } @@ -1154,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()); } @@ -1170,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()); } @@ -1184,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()); } @@ -1195,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()); } @@ -1207,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 { @@ -1319,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, &[])); @@ -1333,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; @@ -1543,6 +1496,29 @@ fn perlin_noise_1d(x: f64) -> f64 { (d0 + s * (d1 - d0)) * 0.5 + 0.5 } +fn pop(stack: &mut Vec) -> Result { + stack.pop().ok_or_else(|| "stack underflow".to_string()) +} + +fn pop_int(stack: &mut Vec) -> Result { + pop(stack)?.as_int() +} + +fn pop_float(stack: &mut Vec) -> Result { + pop(stack)?.as_float() +} + +fn pop_bool(stack: &mut Vec) -> Result { + 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) @@ -1576,8 +1552,8 @@ fn binary_op(stack: &mut Vec, 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(()) } @@ -1586,8 +1562,8 @@ fn cmp_op(stack: &mut Vec, 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 { diff --git a/crates/ratatui/src/spectrum.rs b/crates/ratatui/src/spectrum.rs index b40db9a..c066400 100644 --- a/crates/ratatui/src/spectrum.rs +++ b/crates/ratatui/src/spectrum.rs @@ -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; } } } diff --git a/src/input.rs b/src/input.rs deleted file mode 100644 index 197411b..0000000 --- a/src/input.rs +++ /dev/null @@ -1,1745 +0,0 @@ -use arc_swap::ArcSwap; -use crossbeam_channel::Sender; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; -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::model::PatternSpeed; -use crate::page::Page; -use crate::state::{ - ConfirmAction, CyclicEnum, DeviceKind, EditorTarget, EngineSection, EuclideanField, Modal, - OptionsFocus, PanelFocus, PatternField, PatternPropsField, RenameTarget, SampleBrowserState, - SettingKind, SidePanel, -}; - -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, - pub audio_tx: &'a ArcSwap>, - pub seq_cmd_tx: &'a Sender, - pub nudge_us: &'a Arc, -} - -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 { - 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 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) -> 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, - }, - } -} - -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)); - 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::() { - 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::() { - 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(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::() { - *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::() { - *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 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 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; - } - _ => {} - } - } - - match ctx.app.page { - Page::Main => handle_main_page(ctx, key, ctrl), - Page::Patterns => handle_patterns_page(ctx, key), - Page::Engine => handle_engine_page(ctx, key), - Page::Options => handle_options_page(ctx, key), - Page::Help => handle_help_page(ctx, key), - Page::Dict => handle_dict_page(ctx, key), - } -} - -fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { - use crate::engine::AudioCommand; - use cagire_ratatui::TreeLineKind; - - 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 -} - -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 = 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 -} - -fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { - use crate::state::PatternsColumn; - - 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 -} - -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 -} - -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 -} - -fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { - use crate::state::HelpFocus; - - 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 -} - -fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { - use crate::state::DictFocus; - - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - - // Handle search input mode - 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 -} - -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" - ))); - } -} diff --git a/src/input/engine_page.rs b/src/input/engine_page.rs new file mode 100644 index 0000000..e069bea --- /dev/null +++ b/src/input/engine_page.rs @@ -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 +} diff --git a/src/input/help_page.rs b/src/input/help_page.rs new file mode 100644 index 0000000..79938cc --- /dev/null +++ b/src/input/help_page.rs @@ -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 +} diff --git a/src/input/main_page.rs b/src/input/main_page.rs new file mode 100644 index 0000000..a23313f --- /dev/null +++ b/src/input/main_page.rs @@ -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 = 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 +} diff --git a/src/input/mod.rs b/src/input/mod.rs new file mode 100644 index 0000000..c7e2124 --- /dev/null +++ b/src/input/mod.rs @@ -0,0 +1,167 @@ +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, + pub audio_tx: &'a ArcSwap>, + pub seq_cmd_tx: &'a Sender, + pub nudge_us: &'a Arc, +} + +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; + } + _ => {} + } + } + + 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" + ))); + } +} diff --git a/src/input/modal.rs b/src/input/modal.rs new file mode 100644 index 0000000..404b311 --- /dev/null +++ b/src/input/modal.rs @@ -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::() { + 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::() { + 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::() { + *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::() { + *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) -> 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, + }, + } +} diff --git a/src/input/options_page.rs b/src/input/options_page.rs new file mode 100644 index 0000000..3b3ffc5 --- /dev/null +++ b/src/input/options_page.rs @@ -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 +} diff --git a/src/input/panel.rs b/src/input/panel.rs new file mode 100644 index 0000000..e2c4095 --- /dev/null +++ b/src/input/panel.rs @@ -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 +} diff --git a/src/input/patterns_page.rs b/src/input/patterns_page.rs new file mode 100644 index 0000000..9eb7399 --- /dev/null +++ b/src/input/patterns_page.rs @@ -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 +} diff --git a/src/views/render.rs b/src/views/render.rs index 0814a8a..c004a8a 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -26,24 +26,26 @@ use super::{ dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view, }; +fn clip_span(span: SourceSpan, line_start: usize, line_len: usize) -> Option { + let ls = line_start as u32; + let ll = line_len as u32; + if span.end <= ls || span.start >= ls + ll { + return None; + } + Some(SourceSpan { + start: span.start.max(ls) - ls, + end: span.end.min(ls + ll) - ls, + }) +} + fn adjust_spans_for_line( spans: &[SourceSpan], line_start: usize, line_len: usize, ) -> Vec { - let ls = line_start as u32; - let ll = line_len as u32; spans .iter() - .filter_map(|s| { - if s.end <= ls || s.start >= ls + ll { - return None; - } - Some(SourceSpan { - start: s.start.max(ls) - ls, - end: (s.end.min(ls + ll)) - ls, - }) - }) + .filter_map(|s| clip_span(*s, line_start, line_len)) .collect() } @@ -52,22 +54,9 @@ fn adjust_resolved_for_line( line_start: usize, line_len: usize, ) -> Vec<(SourceSpan, String)> { - let ls = line_start as u32; - let ll = line_len as u32; resolved .iter() - .filter_map(|(s, display)| { - if s.end <= ls || s.start >= ls + ll { - return None; - } - Some(( - SourceSpan { - start: s.start.max(ls) - ls, - end: (s.end.min(ls + ll)) - ls, - }, - display.clone(), - )) - }) + .filter_map(|(s, display)| clip_span(*s, line_start, line_len).map(|cs| (cs, display.clone()))) .collect() } @@ -564,252 +553,8 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term .height(18) .render_centered(frame, term) } - Modal::Preview => { - let width = (term.width * 80 / 100).max(40); - let height = (term.height * 80 / 100).max(10); - - let pattern = app.current_edit_pattern(); - let step_idx = app.editor_ctx.step; - let step = pattern.step(step_idx); - let source_idx = step.and_then(|s| s.source); - let step_name = step.and_then(|s| s.name.as_ref()); - - let title = match (source_idx, step_name) { - (Some(src), Some(name)) => { - format!("Step {:02}: {} → {:02}", step_idx + 1, name, src + 1) - } - (None, Some(name)) => format!("Step {:02}: {}", step_idx + 1, name), - (Some(src), None) => format!("Step {:02} → {:02}", step_idx + 1, src + 1), - (None, None) => format!("Step {:02}", step_idx + 1), - }; - - let inner = ModalFrame::new(&title) - .width(width) - .height(height) - .border_color(theme.modal.preview) - .render_centered(frame, term); - - let script = pattern.resolve_script(step_idx).unwrap_or(""); - if script.is_empty() { - let empty = Paragraph::new("(empty)") - .alignment(Alignment::Center) - .style(Style::new().fg(theme.ui.text_dim)); - let centered_area = Rect { - y: inner.y + inner.height / 2, - height: 1, - ..inner - }; - frame.render_widget(empty, centered_area); - } else { - let trace = if app.ui.runtime_highlight && app.playback.playing { - let source = pattern.resolve_source(step_idx); - snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source) - } else { - None - }; - - let resolved_display: Vec<(SourceSpan, String)> = trace - .map(|t| { - t.resolved - .iter() - .map(|(s, v)| (*s, v.display())) - .collect() - }) - .unwrap_or_default(); - - let mut line_start = 0usize; - let lines: Vec = script - .lines() - .map(|line_str| { - let tokens = if let Some(t) = trace { - let exec = adjust_spans_for_line( - &t.executed_spans, - line_start, - line_str.len(), - ); - let sel = adjust_spans_for_line( - &t.selected_spans, - line_start, - line_str.len(), - ); - let res = adjust_resolved_for_line( - &resolved_display, - line_start, - line_str.len(), - ); - highlight_line_with_runtime(line_str, &exec, &sel, &res, &user_words) - } else { - highlight_line_with_runtime(line_str, &[], &[], &[], &user_words) - }; - line_start += line_str.len() + 1; - let spans: Vec = tokens - .into_iter() - .map(|(style, text, _)| Span::styled(text, style)) - .collect(); - Line::from(spans) - }) - .collect(); - - let paragraph = Paragraph::new(lines); - frame.render_widget(paragraph, inner); - } - - inner - } - Modal::Editor => { - let width = (term.width * 80 / 100).max(40); - let height = (term.height * 60 / 100).max(10); - - let flash_kind = app.ui.flash_kind(); - let border_color = match flash_kind { - Some(FlashKind::Error) => theme.flash.error_fg, - Some(FlashKind::Info) => theme.ui.text_primary, - Some(FlashKind::Success) => theme.flash.success_fg, - None => theme.modal.editor, - }; - - let title = match app.editor_ctx.target { - EditorTarget::Prelude => "Prelude".to_string(), - EditorTarget::Step => { - let step_num = app.editor_ctx.step + 1; - let step = app.current_edit_pattern().step(app.editor_ctx.step); - if let Some(ref name) = step.and_then(|s| s.name.as_ref()) { - format!("Step {step_num:02}: {name}") - } else { - format!("Step {step_num:02} Script") - } - } - }; - - let inner = ModalFrame::new(&title) - .width(width) - .height(height) - .border_color(border_color) - .render_centered(frame, term); - - let trace = if app.ui.runtime_highlight - && app.playback.playing - && app.editor_ctx.target == EditorTarget::Step - { - let source = app - .current_edit_pattern() - .resolve_source(app.editor_ctx.step); - snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source) - } else { - None - }; - - let text_lines = app.editor_ctx.editor.lines(); - let mut line_offsets: Vec = Vec::with_capacity(text_lines.len()); - let mut offset = 0; - for line in text_lines.iter() { - line_offsets.push(offset); - offset += line.len() + 1; - } - - let resolved_display: Vec<(SourceSpan, String)> = trace - .map(|t| { - t.resolved - .iter() - .map(|(s, v)| (*s, v.display())) - .collect() - }) - .unwrap_or_default(); - - let highlighter = |row: usize, line: &str| -> Vec<(Style, String, bool)> { - let line_start = line_offsets[row]; - let (exec, sel, res) = match trace { - Some(t) => ( - adjust_spans_for_line(&t.executed_spans, line_start, line.len()), - adjust_spans_for_line(&t.selected_spans, line_start, line.len()), - adjust_resolved_for_line(&resolved_display, line_start, line.len()), - ), - None => (Vec::new(), Vec::new(), Vec::new()), - }; - highlight::highlight_line_with_runtime(line, &exec, &sel, &res, &user_words) - }; - - let show_search = app.editor_ctx.editor.search_active() - || !app.editor_ctx.editor.search_query().is_empty(); - - let reserved_lines = 1 + if show_search { 1 } else { 0 }; - let editor_height = inner.height.saturating_sub(reserved_lines); - - let mut y = inner.y; - - let search_area = if show_search { - let area = Rect::new(inner.x, y, inner.width, 1); - y += 1; - Some(area) - } else { - None - }; - - let editor_area = Rect::new(inner.x, y, inner.width, editor_height); - y += editor_height; - - let hint_area = Rect::new(inner.x, y, inner.width, 1); - - if let Some(sa) = search_area { - render_search_bar( - frame, - sa, - app.editor_ctx.editor.search_query(), - app.editor_ctx.editor.search_active(), - ); - } - - if let Some(kind) = flash_kind { - let bg = match kind { - FlashKind::Error => theme.flash.error_bg, - FlashKind::Info => theme.flash.info_bg, - FlashKind::Success => theme.flash.success_bg, - }; - let flash_block = Block::default().style(Style::default().bg(bg)); - frame.render_widget(flash_block, editor_area); - } - app.editor_ctx - .editor - .render(frame, editor_area, &highlighter); - - if app.editor_ctx.editor.search_active() { - let hints = hint_line(&[("Enter", "confirm"), ("Esc", "cancel")]); - frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area); - } else if app.editor_ctx.show_stack { - let stack_text = app - .editor_ctx - .stack_cache - .borrow() - .as_ref() - .map(|c| c.result.clone()) - .unwrap_or_else(|| "Stack: []".to_string()); - let hints = hint_line(&[("Esc", "save"), ("C-e", "eval"), ("C-s", "hide")]); - let [hint_left, stack_right] = Layout::horizontal([ - Constraint::Length(hints.width() as u16), - Constraint::Fill(1), - ]) - .areas(hint_area); - frame.render_widget(Paragraph::new(hints), hint_left); - let dim = Style::default().fg(theme.hint.text); - frame.render_widget( - Paragraph::new(Span::styled(stack_text, dim)).alignment(Alignment::Right), - stack_right, - ); - } else { - let hints = hint_line(&[ - ("Esc", "save"), - ("C-e", "eval"), - ("C-f", "find"), - ("C-b", "samples"), - ("C-s", "stack"), - ("C-u", "/"), - ("C-r", "undo/redo"), - ]); - frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area); - } - - inner - } + Modal::Preview => render_modal_preview(frame, app, snapshot, &user_words, term), + Modal::Editor => render_modal_editor(frame, app, snapshot, &user_words, term), Modal::PatternProps { bank, pattern, @@ -846,72 +591,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term inner } - Modal::KeybindingsHelp { scroll } => { - let width = (term.width * 80 / 100).clamp(60, 100); - let height = (term.height * 80 / 100).max(15); - - let title = format!("Keybindings — {}", app.page.name()); - let inner = ModalFrame::new(&title) - .width(width) - .height(height) - .border_color(theme.modal.editor) - .render_centered(frame, term); - - let bindings = super::keybindings::bindings_for(app.page); - let visible_rows = inner.height.saturating_sub(2) as usize; - - let rows: Vec = bindings - .iter() - .enumerate() - .skip(*scroll) - .take(visible_rows) - .map(|(i, (key, name, desc))| { - let bg = if i % 2 == 0 { - theme.table.row_even - } else { - theme.table.row_odd - }; - Row::new(vec![ - Cell::from(*key).style(Style::default().fg(theme.modal.confirm)), - Cell::from(*name).style(Style::default().fg(theme.modal.input)), - Cell::from(*desc).style(Style::default().fg(theme.ui.text_primary)), - ]) - .style(Style::default().bg(bg)) - }) - .collect(); - - let table = Table::new( - rows, - [ - Constraint::Length(14), - Constraint::Length(12), - Constraint::Fill(1), - ], - ) - .column_spacing(2); - - let table_area = Rect { - x: inner.x, - y: inner.y, - width: inner.width, - height: inner.height.saturating_sub(1), - }; - frame.render_widget(table, table_area); - - let hint_area = Rect { - x: inner.x, - y: inner.y + inner.height.saturating_sub(1), - width: inner.width, - height: 1, - }; - let hints = hint_line(&[("↑↓", "scroll"), ("PgUp/Dn", "page"), ("Esc/?", "close")]); - frame.render_widget( - Paragraph::new(hints).alignment(Alignment::Right), - hint_area, - ); - - inner - } + Modal::KeybindingsHelp { scroll } => render_modal_keybindings(frame, app, *scroll, term), Modal::EuclideanDistribution { source_step, field, @@ -969,6 +649,336 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term )) } +fn render_modal_preview( + frame: &mut Frame, + app: &App, + snapshot: &SequencerSnapshot, + user_words: &HashSet, + term: Rect, +) -> Rect { + let theme = theme::get(); + let width = (term.width * 80 / 100).max(40); + let height = (term.height * 80 / 100).max(10); + + let pattern = app.current_edit_pattern(); + let step_idx = app.editor_ctx.step; + let step = pattern.step(step_idx); + let source_idx = step.and_then(|s| s.source); + let step_name = step.and_then(|s| s.name.as_ref()); + + let title = match (source_idx, step_name) { + (Some(src), Some(name)) => { + format!("Step {:02}: {} → {:02}", step_idx + 1, name, src + 1) + } + (None, Some(name)) => format!("Step {:02}: {}", step_idx + 1, name), + (Some(src), None) => format!("Step {:02} → {:02}", step_idx + 1, src + 1), + (None, None) => format!("Step {:02}", step_idx + 1), + }; + + let inner = ModalFrame::new(&title) + .width(width) + .height(height) + .border_color(theme.modal.preview) + .render_centered(frame, term); + + let script = pattern.resolve_script(step_idx).unwrap_or(""); + if script.is_empty() { + let empty = Paragraph::new("(empty)") + .alignment(Alignment::Center) + .style(Style::new().fg(theme.ui.text_dim)); + let centered_area = Rect { + y: inner.y + inner.height / 2, + height: 1, + ..inner + }; + frame.render_widget(empty, centered_area); + } else { + let trace = if app.ui.runtime_highlight && app.playback.playing { + let source = pattern.resolve_source(step_idx); + snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source) + } else { + None + }; + + let resolved_display: Vec<(SourceSpan, String)> = trace + .map(|t| { + t.resolved + .iter() + .map(|(s, v)| (*s, v.display())) + .collect() + }) + .unwrap_or_default(); + + let mut line_start = 0usize; + let lines: Vec = script + .lines() + .map(|line_str| { + let tokens = if let Some(t) = trace { + let exec = adjust_spans_for_line( + &t.executed_spans, + line_start, + line_str.len(), + ); + let sel = adjust_spans_for_line( + &t.selected_spans, + line_start, + line_str.len(), + ); + let res = adjust_resolved_for_line( + &resolved_display, + line_start, + line_str.len(), + ); + highlight_line_with_runtime(line_str, &exec, &sel, &res, user_words) + } else { + highlight_line_with_runtime(line_str, &[], &[], &[], user_words) + }; + line_start += line_str.len() + 1; + let spans: Vec = tokens + .into_iter() + .map(|(style, text, _)| Span::styled(text, style)) + .collect(); + Line::from(spans) + }) + .collect(); + + let paragraph = Paragraph::new(lines); + frame.render_widget(paragraph, inner); + } + + inner +} + +fn render_modal_editor( + frame: &mut Frame, + app: &App, + snapshot: &SequencerSnapshot, + user_words: &HashSet, + term: Rect, +) -> Rect { + let theme = theme::get(); + let width = (term.width * 80 / 100).max(40); + let height = (term.height * 60 / 100).max(10); + + let flash_kind = app.ui.flash_kind(); + let border_color = match flash_kind { + Some(FlashKind::Error) => theme.flash.error_fg, + Some(FlashKind::Info) => theme.ui.text_primary, + Some(FlashKind::Success) => theme.flash.success_fg, + None => theme.modal.editor, + }; + + let title = match app.editor_ctx.target { + EditorTarget::Prelude => "Prelude".to_string(), + EditorTarget::Step => { + let step_num = app.editor_ctx.step + 1; + let step = app.current_edit_pattern().step(app.editor_ctx.step); + if let Some(ref name) = step.and_then(|s| s.name.as_ref()) { + format!("Step {step_num:02}: {name}") + } else { + format!("Step {step_num:02} Script") + } + } + }; + + let inner = ModalFrame::new(&title) + .width(width) + .height(height) + .border_color(border_color) + .render_centered(frame, term); + + let trace = if app.ui.runtime_highlight + && app.playback.playing + && app.editor_ctx.target == EditorTarget::Step + { + let source = app + .current_edit_pattern() + .resolve_source(app.editor_ctx.step); + snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source) + } else { + None + }; + + let text_lines = app.editor_ctx.editor.lines(); + let mut line_offsets: Vec = Vec::with_capacity(text_lines.len()); + let mut offset = 0; + for line in text_lines.iter() { + line_offsets.push(offset); + offset += line.len() + 1; + } + + let resolved_display: Vec<(SourceSpan, String)> = trace + .map(|t| { + t.resolved + .iter() + .map(|(s, v)| (*s, v.display())) + .collect() + }) + .unwrap_or_default(); + + let highlighter = |row: usize, line: &str| -> Vec<(Style, String, bool)> { + let line_start = line_offsets[row]; + let (exec, sel, res) = match trace { + Some(t) => ( + adjust_spans_for_line(&t.executed_spans, line_start, line.len()), + adjust_spans_for_line(&t.selected_spans, line_start, line.len()), + adjust_resolved_for_line(&resolved_display, line_start, line.len()), + ), + None => (Vec::new(), Vec::new(), Vec::new()), + }; + highlight::highlight_line_with_runtime(line, &exec, &sel, &res, user_words) + }; + + let show_search = app.editor_ctx.editor.search_active() + || !app.editor_ctx.editor.search_query().is_empty(); + + let reserved_lines = 1 + if show_search { 1 } else { 0 }; + let editor_height = inner.height.saturating_sub(reserved_lines); + + let mut y = inner.y; + + let search_area = if show_search { + let area = Rect::new(inner.x, y, inner.width, 1); + y += 1; + Some(area) + } else { + None + }; + + let editor_area = Rect::new(inner.x, y, inner.width, editor_height); + y += editor_height; + + let hint_area = Rect::new(inner.x, y, inner.width, 1); + + if let Some(sa) = search_area { + render_search_bar( + frame, + sa, + app.editor_ctx.editor.search_query(), + app.editor_ctx.editor.search_active(), + ); + } + + if let Some(kind) = flash_kind { + let bg = match kind { + FlashKind::Error => theme.flash.error_bg, + FlashKind::Info => theme.flash.info_bg, + FlashKind::Success => theme.flash.success_bg, + }; + let flash_block = Block::default().style(Style::default().bg(bg)); + frame.render_widget(flash_block, editor_area); + } + app.editor_ctx + .editor + .render(frame, editor_area, &highlighter); + + if app.editor_ctx.editor.search_active() { + let hints = hint_line(&[("Enter", "confirm"), ("Esc", "cancel")]); + frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area); + } else if app.editor_ctx.show_stack { + let stack_text = app + .editor_ctx + .stack_cache + .borrow() + .as_ref() + .map(|c| c.result.clone()) + .unwrap_or_else(|| "Stack: []".to_string()); + let hints = hint_line(&[("Esc", "save"), ("C-e", "eval"), ("C-s", "hide")]); + let [hint_left, stack_right] = Layout::horizontal([ + Constraint::Length(hints.width() as u16), + Constraint::Fill(1), + ]) + .areas(hint_area); + frame.render_widget(Paragraph::new(hints), hint_left); + let dim = Style::default().fg(theme.hint.text); + frame.render_widget( + Paragraph::new(Span::styled(stack_text, dim)).alignment(Alignment::Right), + stack_right, + ); + } else { + let hints = hint_line(&[ + ("Esc", "save"), + ("C-e", "eval"), + ("C-f", "find"), + ("C-b", "samples"), + ("C-s", "stack"), + ("C-u", "/"), + ("C-r", "undo/redo"), + ]); + frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area); + } + + inner +} + +fn render_modal_keybindings(frame: &mut Frame, app: &App, scroll: usize, term: Rect) -> Rect { + let theme = theme::get(); + let width = (term.width * 80 / 100).clamp(60, 100); + let height = (term.height * 80 / 100).max(15); + + let title = format!("Keybindings — {}", app.page.name()); + let inner = ModalFrame::new(&title) + .width(width) + .height(height) + .border_color(theme.modal.editor) + .render_centered(frame, term); + + let bindings = super::keybindings::bindings_for(app.page); + let visible_rows = inner.height.saturating_sub(2) as usize; + + let rows: Vec = bindings + .iter() + .enumerate() + .skip(scroll) + .take(visible_rows) + .map(|(i, (key, name, desc))| { + let bg = if i % 2 == 0 { + theme.table.row_even + } else { + theme.table.row_odd + }; + Row::new(vec![ + Cell::from(*key).style(Style::default().fg(theme.modal.confirm)), + Cell::from(*name).style(Style::default().fg(theme.modal.input)), + Cell::from(*desc).style(Style::default().fg(theme.ui.text_primary)), + ]) + .style(Style::default().bg(bg)) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(14), + Constraint::Length(12), + Constraint::Fill(1), + ], + ) + .column_spacing(2); + + let table_area = Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: inner.height.saturating_sub(1), + }; + frame.render_widget(table, table_area); + + let hint_area = Rect { + x: inner.x, + y: inner.y + inner.height.saturating_sub(1), + width: inner.width, + height: 1, + }; + let hints = hint_line(&[("↑↓", "scroll"), ("PgUp/Dn", "page"), ("Esc/?", "close")]); + frame.render_widget( + Paragraph::new(hints).alignment(Alignment::Right), + hint_area, + ); + + inner +} + fn format_euclidean_preview(pulses: usize, steps: usize, rotation: usize) -> String { if pulses == 0 || steps == 0 || pulses > steps { return "[invalid]".to_string();