Compare commits
9 Commits
v0.1.1
...
5a72e4cef4
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a72e4cef4 | |||
| 0097777449 | |||
| 4743c33916 | |||
| 2c8a6794a3 | |||
| 60fb62829f | |||
| 35370a6f2c | |||
| 4e1c04f9c7 | |||
| 80a3d91f76 | |||
| f130c9b54a |
2
.github/workflows/build-cross.yml
vendored
2
.github/workflows/build-cross.yml
vendored
@@ -10,7 +10,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
2
.github/workflows/build-linux.yml
vendored
2
.github/workflows/build-linux.yml
vendored
@@ -30,7 +30,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
2
.github/workflows/build-macos.yml
vendored
2
.github/workflows/build-macos.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
|||||||
include: ${{ fromJSON(inputs.matrix) }}
|
include: ${{ fromJSON(inputs.matrix) }}
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 30
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
2
.github/workflows/build-plugins-linux.yml
vendored
2
.github/workflows/build-plugins-linux.yml
vendored
@@ -10,7 +10,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
2
.github/workflows/build-plugins-macos.yml
vendored
2
.github/workflows/build-plugins-macos.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
include: ${{ fromJSON(inputs.matrix) }}
|
include: ${{ fromJSON(inputs.matrix) }}
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 30
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
2
.github/workflows/build-plugins-rpi.yml
vendored
2
.github/workflows/build-plugins-rpi.yml
vendored
@@ -10,7 +10,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
2
.github/workflows/build-plugins-windows.yml
vendored
2
.github/workflows/build-plugins-windows.yml
vendored
@@ -10,7 +10,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 60
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
|
|||||||
2
.github/workflows/build-windows.yml
vendored
2
.github/workflows/build-windows.yml
vendored
@@ -30,7 +30,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ impl Forth {
|
|||||||
let vars_snapshot = self.vars.load_full();
|
let vars_snapshot = self.vars.load_full();
|
||||||
let mut var_writes: HashMap<String, Value> = HashMap::new();
|
let mut var_writes: HashMap<String, Value> = HashMap::new();
|
||||||
|
|
||||||
cmd.set_global(self.global_params.lock().clone());
|
cmd.set_global(std::mem::take(&mut *self.global_params.lock()));
|
||||||
|
|
||||||
self.execute_ops(
|
self.execute_ops(
|
||||||
ops,
|
ops,
|
||||||
@@ -459,7 +459,7 @@ impl Forth {
|
|||||||
if b.as_float().map_or(true, |v| v == 0.0) {
|
if b.as_float().map_or(true, |v| v == 0.0) {
|
||||||
return Err("division by zero".into());
|
return Err("division by zero".into());
|
||||||
}
|
}
|
||||||
stack.push(lift_binary(a, b, |x, y| x / y)?);
|
stack.push(lift_binary(&a, &b, |x, y| x / y)?);
|
||||||
}
|
}
|
||||||
Op::Mod => {
|
Op::Mod => {
|
||||||
let b = pop(stack)?;
|
let b = pop(stack)?;
|
||||||
@@ -467,47 +467,47 @@ impl Forth {
|
|||||||
if b.as_float().map_or(true, |v| v == 0.0) {
|
if b.as_float().map_or(true, |v| v == 0.0) {
|
||||||
return Err("modulo by zero".into());
|
return Err("modulo by zero".into());
|
||||||
}
|
}
|
||||||
let result = lift_binary(a, b, |x, y| (x as i64 % y as i64) as f64)?;
|
let result = lift_binary(&a, &b, |x, y| (x as i64 % y as i64) as f64)?;
|
||||||
stack.push(result);
|
stack.push(result);
|
||||||
}
|
}
|
||||||
Op::Neg => {
|
Op::Neg => {
|
||||||
let v = pop(stack)?;
|
let v = pop(stack)?;
|
||||||
stack.push(lift_unary(v, |x| -x)?);
|
stack.push(lift_unary(&v, |x| -x)?);
|
||||||
}
|
}
|
||||||
Op::Abs => {
|
Op::Abs => {
|
||||||
let v = pop(stack)?;
|
let v = pop(stack)?;
|
||||||
stack.push(lift_unary(v, |x| x.abs())?);
|
stack.push(lift_unary(&v, |x| x.abs())?);
|
||||||
}
|
}
|
||||||
Op::Floor => {
|
Op::Floor => {
|
||||||
let v = pop(stack)?;
|
let v = pop(stack)?;
|
||||||
stack.push(lift_unary(v, |x| x.floor())?);
|
stack.push(lift_unary(&v, |x| x.floor())?);
|
||||||
}
|
}
|
||||||
Op::Ceil => {
|
Op::Ceil => {
|
||||||
let v = pop(stack)?;
|
let v = pop(stack)?;
|
||||||
stack.push(lift_unary(v, |x| x.ceil())?);
|
stack.push(lift_unary(&v, |x| x.ceil())?);
|
||||||
}
|
}
|
||||||
Op::Round => {
|
Op::Round => {
|
||||||
let v = pop(stack)?;
|
let v = pop(stack)?;
|
||||||
stack.push(lift_unary(v, |x| x.round())?);
|
stack.push(lift_unary(&v, |x| x.round())?);
|
||||||
}
|
}
|
||||||
Op::Min => binary_op(stack, |a, b| a.min(b))?,
|
Op::Min => binary_op(stack, |a, b| a.min(b))?,
|
||||||
Op::Max => binary_op(stack, |a, b| a.max(b))?,
|
Op::Max => binary_op(stack, |a, b| a.max(b))?,
|
||||||
Op::Pow => binary_op(stack, |a, b| a.powf(b))?,
|
Op::Pow => binary_op(stack, |a, b| a.powf(b))?,
|
||||||
Op::Sqrt => {
|
Op::Sqrt => {
|
||||||
let v = pop(stack)?;
|
let v = pop(stack)?;
|
||||||
stack.push(lift_unary(v, |x| x.sqrt())?);
|
stack.push(lift_unary(&v, |x| x.sqrt())?);
|
||||||
}
|
}
|
||||||
Op::Sin => {
|
Op::Sin => {
|
||||||
let v = pop(stack)?;
|
let v = pop(stack)?;
|
||||||
stack.push(lift_unary(v, |x| x.sin())?);
|
stack.push(lift_unary(&v, |x| x.sin())?);
|
||||||
}
|
}
|
||||||
Op::Cos => {
|
Op::Cos => {
|
||||||
let v = pop(stack)?;
|
let v = pop(stack)?;
|
||||||
stack.push(lift_unary(v, |x| x.cos())?);
|
stack.push(lift_unary(&v, |x| x.cos())?);
|
||||||
}
|
}
|
||||||
Op::Log => {
|
Op::Log => {
|
||||||
let v = pop(stack)?;
|
let v = pop(stack)?;
|
||||||
stack.push(lift_unary(v, |x| x.ln())?);
|
stack.push(lift_unary(&v, |x| x.ln())?);
|
||||||
}
|
}
|
||||||
|
|
||||||
Op::Eq => cmp_op(stack, |a, b| (a - b).abs() < f64::EPSILON)?,
|
Op::Eq => cmp_op(stack, |a, b| (a - b).abs() < f64::EPSILON)?,
|
||||||
@@ -1055,7 +1055,7 @@ impl Forth {
|
|||||||
let key = read_key(&var_writes_cell, vars_snapshot);
|
let key = read_key(&var_writes_cell, vars_snapshot);
|
||||||
let values = std::mem::take(stack);
|
let values = std::mem::take(stack);
|
||||||
for val in values {
|
for val in values {
|
||||||
let result = lift_unary_int(val, |degree| {
|
let result = lift_unary_int(&val, |degree| {
|
||||||
let octave_offset = degree.div_euclid(len);
|
let octave_offset = degree.div_euclid(len);
|
||||||
let idx = degree.rem_euclid(len) as usize;
|
let idx = degree.rem_euclid(len) as usize;
|
||||||
key + octave_offset * 12 + pattern[idx]
|
key + octave_offset * 12 + pattern[idx]
|
||||||
@@ -1155,7 +1155,7 @@ impl Forth {
|
|||||||
Op::Oct => {
|
Op::Oct => {
|
||||||
let shift = pop(stack)?;
|
let shift = pop(stack)?;
|
||||||
let note = pop(stack)?;
|
let note = pop(stack)?;
|
||||||
let result = lift_binary(note, shift, |n, s| n + s * 12.0)?;
|
let result = lift_binary(¬e, &shift, |n, s| n + s * 12.0)?;
|
||||||
stack.push(result);
|
stack.push(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1573,7 +1573,7 @@ impl Forth {
|
|||||||
} else {
|
} else {
|
||||||
let note = get_int("note").unwrap_or(60).clamp(0, 127) as u8;
|
let note = get_int("note").unwrap_or(60).clamp(0, 127) as u8;
|
||||||
let velocity =
|
let velocity =
|
||||||
get_int("velocity").unwrap_or(100).clamp(0, 127) as u8;
|
(get_float("velocity").unwrap_or(0.8) * 127.0).clamp(0.0, 127.0) as u8;
|
||||||
let dur = get_float("dur").unwrap_or(1.0);
|
let dur = get_float("dur").unwrap_or(1.0);
|
||||||
let dur_secs = dur * ctx.step_duration();
|
let dur_secs = dur * ctx.step_duration();
|
||||||
outputs.push(format!(
|
outputs.push(format!(
|
||||||
@@ -1921,65 +1921,65 @@ fn float_to_value(result: f64) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lift_unary<F>(val: Value, f: F) -> Result<Value, String>
|
fn lift_unary<F>(val: &Value, f: F) -> Result<Value, String>
|
||||||
where
|
where
|
||||||
F: Fn(f64) -> f64 + Copy,
|
F: Fn(f64) -> f64 + Copy,
|
||||||
{
|
{
|
||||||
match val {
|
match val {
|
||||||
Value::ArpList(items) => {
|
Value::ArpList(items) => {
|
||||||
let mapped: Result<Vec<_>, _> = items.iter().map(|x| lift_unary(x.clone(), f)).collect();
|
let mapped: Result<Vec<_>, _> = items.iter().map(|x| lift_unary(x, f)).collect();
|
||||||
Ok(Value::ArpList(Arc::from(mapped?)))
|
Ok(Value::ArpList(Arc::from(mapped?)))
|
||||||
}
|
}
|
||||||
Value::CycleList(items) => {
|
Value::CycleList(items) => {
|
||||||
let mapped: Result<Vec<_>, _> = items.iter().map(|x| lift_unary(x.clone(), f)).collect();
|
let mapped: Result<Vec<_>, _> = items.iter().map(|x| lift_unary(x, f)).collect();
|
||||||
Ok(Value::CycleList(Arc::from(mapped?)))
|
Ok(Value::CycleList(Arc::from(mapped?)))
|
||||||
}
|
}
|
||||||
v => Ok(float_to_value(f(v.as_float()?))),
|
v => Ok(float_to_value(f(v.as_float()?))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lift_unary_int<F>(val: Value, f: F) -> Result<Value, String>
|
fn lift_unary_int<F>(val: &Value, f: F) -> Result<Value, String>
|
||||||
where
|
where
|
||||||
F: Fn(i64) -> i64 + Copy,
|
F: Fn(i64) -> i64 + Copy,
|
||||||
{
|
{
|
||||||
match val {
|
match val {
|
||||||
Value::ArpList(items) => {
|
Value::ArpList(items) => {
|
||||||
let mapped: Result<Vec<_>, _> =
|
let mapped: Result<Vec<_>, _> =
|
||||||
items.iter().map(|x| lift_unary_int(x.clone(), f)).collect();
|
items.iter().map(|x| lift_unary_int(x, f)).collect();
|
||||||
Ok(Value::ArpList(Arc::from(mapped?)))
|
Ok(Value::ArpList(Arc::from(mapped?)))
|
||||||
}
|
}
|
||||||
Value::CycleList(items) => {
|
Value::CycleList(items) => {
|
||||||
let mapped: Result<Vec<_>, _> =
|
let mapped: Result<Vec<_>, _> =
|
||||||
items.iter().map(|x| lift_unary_int(x.clone(), f)).collect();
|
items.iter().map(|x| lift_unary_int(x, f)).collect();
|
||||||
Ok(Value::CycleList(Arc::from(mapped?)))
|
Ok(Value::CycleList(Arc::from(mapped?)))
|
||||||
}
|
}
|
||||||
v => Ok(Value::Int(f(v.as_int()?), None)),
|
v => Ok(Value::Int(f(v.as_int()?), None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lift_binary<F>(a: Value, b: Value, f: F) -> Result<Value, String>
|
fn lift_binary<F>(a: &Value, b: &Value, f: F) -> Result<Value, String>
|
||||||
where
|
where
|
||||||
F: Fn(f64, f64) -> f64 + Copy,
|
F: Fn(f64, f64) -> f64 + Copy,
|
||||||
{
|
{
|
||||||
match (a, b) {
|
match (a, b) {
|
||||||
(Value::ArpList(items), b) => {
|
(Value::ArpList(items), b) => {
|
||||||
let mapped: Result<Vec<_>, _> =
|
let mapped: Result<Vec<_>, _> =
|
||||||
items.iter().map(|x| lift_binary(x.clone(), b.clone(), f)).collect();
|
items.iter().map(|x| lift_binary(x, b, f)).collect();
|
||||||
Ok(Value::ArpList(Arc::from(mapped?)))
|
Ok(Value::ArpList(Arc::from(mapped?)))
|
||||||
}
|
}
|
||||||
(a, Value::ArpList(items)) => {
|
(a, Value::ArpList(items)) => {
|
||||||
let mapped: Result<Vec<_>, _> =
|
let mapped: Result<Vec<_>, _> =
|
||||||
items.iter().map(|x| lift_binary(a.clone(), x.clone(), f)).collect();
|
items.iter().map(|x| lift_binary(a, x, f)).collect();
|
||||||
Ok(Value::ArpList(Arc::from(mapped?)))
|
Ok(Value::ArpList(Arc::from(mapped?)))
|
||||||
}
|
}
|
||||||
(Value::CycleList(items), b) => {
|
(Value::CycleList(items), b) => {
|
||||||
let mapped: Result<Vec<_>, _> =
|
let mapped: Result<Vec<_>, _> =
|
||||||
items.iter().map(|x| lift_binary(x.clone(), b.clone(), f)).collect();
|
items.iter().map(|x| lift_binary(x, b, f)).collect();
|
||||||
Ok(Value::CycleList(Arc::from(mapped?)))
|
Ok(Value::CycleList(Arc::from(mapped?)))
|
||||||
}
|
}
|
||||||
(a, Value::CycleList(items)) => {
|
(a, Value::CycleList(items)) => {
|
||||||
let mapped: Result<Vec<_>, _> =
|
let mapped: Result<Vec<_>, _> =
|
||||||
items.iter().map(|x| lift_binary(a.clone(), x.clone(), f)).collect();
|
items.iter().map(|x| lift_binary(a, x, f)).collect();
|
||||||
Ok(Value::CycleList(Arc::from(mapped?)))
|
Ok(Value::CycleList(Arc::from(mapped?)))
|
||||||
}
|
}
|
||||||
(a, b) => Ok(float_to_value(f(a.as_float()?, b.as_float()?))),
|
(a, b) => Ok(float_to_value(f(a.as_float()?, b.as_float()?))),
|
||||||
@@ -1992,7 +1992,7 @@ where
|
|||||||
{
|
{
|
||||||
let b = pop(stack)?;
|
let b = pop(stack)?;
|
||||||
let a = pop(stack)?;
|
let a = pop(stack)?;
|
||||||
stack.push(lift_binary(a, b, f)?);
|
stack.push(lift_binary(&a, &b, f)?);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ pub(super) const WORDS: &[Word] = &[
|
|||||||
aliases: &[],
|
aliases: &[],
|
||||||
category: "Envelope",
|
category: "Envelope",
|
||||||
stack: "(v.. --)",
|
stack: "(v.. --)",
|
||||||
desc: "Set velocity",
|
desc: "Set velocity (0-1)",
|
||||||
example: "100 velocity",
|
example: "0.8 velocity",
|
||||||
compile: Param,
|
compile: Param,
|
||||||
varargs: true,
|
varargs: true,
|
||||||
},
|
},
|
||||||
Word {
|
Word {
|
||||||
name: "attack",
|
name: "attack",
|
||||||
aliases: &["att"],
|
aliases: &["att", "a"],
|
||||||
category: "Envelope",
|
category: "Envelope",
|
||||||
stack: "(v.. --)",
|
stack: "(v.. --)",
|
||||||
desc: "Set attack time",
|
desc: "Set attack time",
|
||||||
@@ -45,7 +45,7 @@ pub(super) const WORDS: &[Word] = &[
|
|||||||
},
|
},
|
||||||
Word {
|
Word {
|
||||||
name: "decay",
|
name: "decay",
|
||||||
aliases: &["dec"],
|
aliases: &["dec", "d"],
|
||||||
category: "Envelope",
|
category: "Envelope",
|
||||||
stack: "(v.. --)",
|
stack: "(v.. --)",
|
||||||
desc: "Set decay time",
|
desc: "Set decay time",
|
||||||
@@ -55,7 +55,7 @@ pub(super) const WORDS: &[Word] = &[
|
|||||||
},
|
},
|
||||||
Word {
|
Word {
|
||||||
name: "sustain",
|
name: "sustain",
|
||||||
aliases: &["sus"],
|
aliases: &["sus", "s"],
|
||||||
category: "Envelope",
|
category: "Envelope",
|
||||||
stack: "(v.. --)",
|
stack: "(v.. --)",
|
||||||
desc: "Set sustain level",
|
desc: "Set sustain level",
|
||||||
@@ -65,7 +65,7 @@ pub(super) const WORDS: &[Word] = &[
|
|||||||
},
|
},
|
||||||
Word {
|
Word {
|
||||||
name: "release",
|
name: "release",
|
||||||
aliases: &["rel"],
|
aliases: &["rel", "r"],
|
||||||
category: "Envelope",
|
category: "Envelope",
|
||||||
stack: "(v.. --)",
|
stack: "(v.. --)",
|
||||||
desc: "Set release time",
|
desc: "Set release time",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ pub(super) const WORDS: &[Word] = &[
|
|||||||
// Sound
|
// Sound
|
||||||
Word {
|
Word {
|
||||||
name: "sound",
|
name: "sound",
|
||||||
aliases: &["s"],
|
aliases: &["snd"],
|
||||||
category: "Sound",
|
category: "Sound",
|
||||||
stack: "(name --)",
|
stack: "(name --)",
|
||||||
desc: "Begin sound command",
|
desc: "Begin sound command",
|
||||||
@@ -377,6 +377,16 @@ pub(super) const WORDS: &[Word] = &[
|
|||||||
compile: Param,
|
compile: Param,
|
||||||
varargs: true,
|
varargs: true,
|
||||||
},
|
},
|
||||||
|
Word {
|
||||||
|
name: "partials",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Oscillator",
|
||||||
|
stack: "(v.. --)",
|
||||||
|
desc: "Set number of active harmonics (add source only)",
|
||||||
|
example: "16 partials",
|
||||||
|
compile: Param,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
Word {
|
Word {
|
||||||
name: "coarse",
|
name: "coarse",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
|
|||||||
@@ -170,6 +170,17 @@ impl LaunchQuantization {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn short_label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Immediate => "Imm",
|
||||||
|
Self::Beat => "Bt",
|
||||||
|
Self::Bar => "1B",
|
||||||
|
Self::Bars2 => "2B",
|
||||||
|
Self::Bars4 => "4B",
|
||||||
|
Self::Bars8 => "8B",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cycle to the next longer quantization, clamped at `Bars8`.
|
/// Cycle to the next longer quantization, clamped at `Bars8`.
|
||||||
pub fn next(&self) -> Self {
|
pub fn next(&self) -> Self {
|
||||||
match self {
|
match self {
|
||||||
@@ -212,6 +223,13 @@ impl SyncMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn short_label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Reset => "Rst",
|
||||||
|
Self::PhaseLock => "Plk",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Toggle between Reset and PhaseLock.
|
/// Toggle between Reset and PhaseLock.
|
||||||
pub fn toggle(&self) -> Self {
|
pub fn toggle(&self) -> Self {
|
||||||
match self {
|
match self {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Script editor widget with completion, search, and sample finder popups.
|
//! Script editor widget with completion, search, and sample finder popups.
|
||||||
|
|
||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
@@ -25,7 +26,7 @@ pub struct CompletionCandidate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct CompletionState {
|
struct CompletionState {
|
||||||
candidates: Vec<CompletionCandidate>,
|
candidates: Arc<[CompletionCandidate]>,
|
||||||
matches: Vec<usize>,
|
matches: Vec<usize>,
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
prefix: String,
|
prefix: String,
|
||||||
@@ -37,7 +38,7 @@ struct CompletionState {
|
|||||||
impl CompletionState {
|
impl CompletionState {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
candidates: Vec::new(),
|
candidates: Arc::from([]),
|
||||||
matches: Vec::new(),
|
matches: Vec::new(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
prefix: String::new(),
|
prefix: String::new(),
|
||||||
@@ -171,7 +172,7 @@ impl Editor {
|
|||||||
self.scroll_offset.set(0);
|
self.scroll_offset.set(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_candidates(&mut self, candidates: Vec<CompletionCandidate>) {
|
pub fn set_candidates(&mut self, candidates: Arc<[CompletionCandidate]>) {
|
||||||
self.completion.candidates = candidates;
|
self.completion.candidates = candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ pub struct FileBrowserModal<'a> {
|
|||||||
title: &'a str,
|
title: &'a str,
|
||||||
input: &'a str,
|
input: &'a str,
|
||||||
entries: &'a [(String, bool, bool)],
|
entries: &'a [(String, bool, bool)],
|
||||||
|
audio_counts: &'a [Option<usize>],
|
||||||
selected: usize,
|
selected: usize,
|
||||||
scroll_offset: usize,
|
scroll_offset: usize,
|
||||||
border_color: Option<Color>,
|
border_color: Option<Color>,
|
||||||
width: u16,
|
width: u16,
|
||||||
height: u16,
|
height: u16,
|
||||||
|
hints: Option<Line<'a>>,
|
||||||
|
color_path: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> FileBrowserModal<'a> {
|
impl<'a> FileBrowserModal<'a> {
|
||||||
@@ -27,11 +30,14 @@ impl<'a> FileBrowserModal<'a> {
|
|||||||
title,
|
title,
|
||||||
input,
|
input,
|
||||||
entries,
|
entries,
|
||||||
|
audio_counts: &[],
|
||||||
selected: 0,
|
selected: 0,
|
||||||
scroll_offset: 0,
|
scroll_offset: 0,
|
||||||
border_color: None,
|
border_color: None,
|
||||||
width: 60,
|
width: 60,
|
||||||
height: 16,
|
height: 16,
|
||||||
|
hints: None,
|
||||||
|
color_path: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +66,21 @@ impl<'a> FileBrowserModal<'a> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn hints(mut self, hints: Line<'a>) -> Self {
|
||||||
|
self.hints = Some(hints);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn audio_counts(mut self, counts: &'a [Option<usize>]) -> Self {
|
||||||
|
self.audio_counts = counts;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn color_path(mut self) -> Self {
|
||||||
|
self.color_path = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect {
|
pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect {
|
||||||
let colors = theme::get();
|
let colors = theme::get();
|
||||||
let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
|
let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
|
||||||
@@ -70,37 +91,61 @@ impl<'a> FileBrowserModal<'a> {
|
|||||||
.border_color(border_color)
|
.border_color(border_color)
|
||||||
.render_centered(frame, term);
|
.render_centered(frame, term);
|
||||||
|
|
||||||
let rows = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner);
|
let has_hints = self.hints.is_some();
|
||||||
|
let constraints = if has_hints {
|
||||||
|
vec![
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![Constraint::Length(1), Constraint::Min(1)]
|
||||||
|
};
|
||||||
|
let rows = Layout::vertical(constraints).split(inner);
|
||||||
|
|
||||||
// Input line
|
// Input line
|
||||||
frame.render_widget(
|
let input_spans = if self.color_path {
|
||||||
Paragraph::new(Line::from(vec![
|
let (path_part, filter_part) = match self.input.rfind('/') {
|
||||||
|
Some(pos) => (&self.input[..=pos], &self.input[pos + 1..]),
|
||||||
|
None => ("", self.input),
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
Span::raw("> "),
|
||||||
|
Span::styled(path_part.to_string(), Style::new().fg(colors.browser.directory)),
|
||||||
|
Span::styled(filter_part.to_string(), Style::new().fg(colors.input.text)),
|
||||||
|
Span::styled("█", Style::new().fg(colors.input.cursor)),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
Span::raw("> "),
|
Span::raw("> "),
|
||||||
Span::styled(self.input, Style::new().fg(colors.input.text)),
|
Span::styled(self.input, Style::new().fg(colors.input.text)),
|
||||||
Span::styled("█", Style::new().fg(colors.input.cursor)),
|
Span::styled("█", Style::new().fg(colors.input.cursor)),
|
||||||
])),
|
]
|
||||||
rows[0],
|
};
|
||||||
);
|
frame.render_widget(Paragraph::new(Line::from(input_spans)), rows[0]);
|
||||||
|
|
||||||
|
// Hints bar
|
||||||
|
if let Some(hints) = self.hints {
|
||||||
|
let hint_row = rows[2];
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(hints).alignment(ratatui::layout::Alignment::Right),
|
||||||
|
hint_row,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Entries list
|
// Entries list
|
||||||
let visible_height = rows[1].height as usize;
|
let visible_height = rows[1].height as usize;
|
||||||
let visible_entries = self
|
let visible_entries = self
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
|
.enumerate()
|
||||||
.skip(self.scroll_offset)
|
.skip(self.scroll_offset)
|
||||||
.take(visible_height);
|
.take(visible_height);
|
||||||
|
|
||||||
let lines: Vec<Line> = visible_entries
|
let lines: Vec<Line> = visible_entries
|
||||||
.enumerate()
|
.map(|(abs_idx, (name, is_dir, is_cagire))| {
|
||||||
.map(|(i, (name, is_dir, is_cagire))| {
|
|
||||||
let abs_idx = i + self.scroll_offset;
|
|
||||||
let is_selected = abs_idx == self.selected;
|
let is_selected = abs_idx == self.selected;
|
||||||
let prefix = if is_selected { "> " } else { " " };
|
let prefix = if is_selected { "> " } else { " " };
|
||||||
let display = if *is_dir {
|
|
||||||
format!("{prefix}{name}/")
|
|
||||||
} else {
|
|
||||||
format!("{prefix}{name}")
|
|
||||||
};
|
|
||||||
let color = if is_selected {
|
let color = if is_selected {
|
||||||
colors.browser.selected
|
colors.browser.selected
|
||||||
} else if *is_dir {
|
} else if *is_dir {
|
||||||
@@ -110,7 +155,21 @@ impl<'a> FileBrowserModal<'a> {
|
|||||||
} else {
|
} else {
|
||||||
colors.browser.file
|
colors.browser.file
|
||||||
};
|
};
|
||||||
Line::from(Span::styled(display, Style::new().fg(color)))
|
let display = if *is_dir {
|
||||||
|
format!("{prefix}{name}/")
|
||||||
|
} else {
|
||||||
|
format!("{prefix}{name}")
|
||||||
|
};
|
||||||
|
let mut spans = vec![Span::styled(display, Style::new().fg(color))];
|
||||||
|
if *is_dir && name != ".." {
|
||||||
|
if let Some(Some(count)) = self.audio_counts.get(abs_idx) {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!(" ({count})"),
|
||||||
|
Style::new().fg(colors.browser.file),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Line::from(spans)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub struct TreeLine {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
pub folder: String,
|
pub folder: String,
|
||||||
pub index: usize,
|
pub index: usize,
|
||||||
|
pub child_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tree-view browser for navigating sample folders.
|
/// Tree-view browser for navigating sample folders.
|
||||||
@@ -136,10 +137,10 @@ impl<'a> SampleBrowser<'a> {
|
|||||||
|
|
||||||
let (icon, icon_color) = match entry.kind {
|
let (icon, icon_color) = match entry.kind {
|
||||||
TreeLineKind::Root { expanded: true } | TreeLineKind::Folder { expanded: true } => {
|
TreeLineKind::Root { expanded: true } | TreeLineKind::Folder { expanded: true } => {
|
||||||
("\u{25BC} ", colors.browser.folder_icon)
|
("\u{2212} ", colors.browser.folder_icon)
|
||||||
}
|
}
|
||||||
TreeLineKind::Root { expanded: false }
|
TreeLineKind::Root { expanded: false }
|
||||||
| TreeLineKind::Folder { expanded: false } => ("\u{25B6} ", colors.browser.folder_icon),
|
| TreeLineKind::Folder { expanded: false } => ("+ ", colors.browser.folder_icon),
|
||||||
TreeLineKind::File => ("\u{266A} ", colors.browser.file_icon),
|
TreeLineKind::File => ("\u{266A} ", colors.browser.file_icon),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,15 +164,43 @@ impl<'a> SampleBrowser<'a> {
|
|||||||
Style::new().fg(icon_color)
|
Style::new().fg(icon_color)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let prefix_width = indent.len() + 2; // indent + icon
|
||||||
|
let suffix = match entry.kind {
|
||||||
|
TreeLineKind::File => format!(" {}", entry.index),
|
||||||
|
TreeLineKind::Root { expanded: false }
|
||||||
|
| TreeLineKind::Folder { expanded: false }
|
||||||
|
if entry.child_count > 0 =>
|
||||||
|
{
|
||||||
|
format!(" ({})", entry.child_count)
|
||||||
|
}
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let max_label = (area.width as usize)
|
||||||
|
.saturating_sub(prefix_width)
|
||||||
|
.saturating_sub(suffix.len());
|
||||||
|
let label: std::borrow::Cow<str> = if entry.label.len() > max_label && max_label > 1 {
|
||||||
|
let truncated: String = entry.label.chars().take(max_label - 1).collect();
|
||||||
|
format!("{}\u{2026}", truncated).into()
|
||||||
|
} else {
|
||||||
|
(&entry.label).into()
|
||||||
|
};
|
||||||
|
|
||||||
let mut spans = vec![
|
let mut spans = vec![
|
||||||
Span::raw(indent),
|
Span::raw(indent),
|
||||||
Span::styled(icon, icon_style),
|
Span::styled(icon, icon_style),
|
||||||
Span::styled(&entry.label, label_style),
|
Span::styled(label, label_style),
|
||||||
];
|
];
|
||||||
|
|
||||||
if matches!(entry.kind, TreeLineKind::File) {
|
match entry.kind {
|
||||||
let idx_style = Style::new().fg(colors.browser.empty_text);
|
TreeLineKind::File => {
|
||||||
spans.push(Span::styled(format!(" {}", entry.index), idx_style));
|
let idx_style = Style::new().fg(colors.browser.empty_text);
|
||||||
|
spans.push(Span::styled(suffix, idx_style));
|
||||||
|
}
|
||||||
|
_ if !suffix.is_empty() => {
|
||||||
|
let dim_style = Style::new().fg(colors.browser.empty_text);
|
||||||
|
spans.push(Span::styled(suffix, dim_style));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(Line::from(spans));
|
lines.push(Line::from(spans));
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Each pattern is an independent sequence of steps with its own properties:
|
|||||||
| Sync Mode | Reset or Phase-Lock on re-trigger | `Reset` |
|
| Sync Mode | Reset or Phase-Lock on re-trigger | `Reset` |
|
||||||
| Follow Up | What happens when the pattern finishes an iteration | `Loop` |
|
| Follow Up | What happens when the pattern finishes an iteration | `Loop` |
|
||||||
|
|
||||||
Press `e` in the patterns view to edit these settings. After editing properties, you will have to hit the `c` key to _commit_ these changes. More about that later!
|
Press `e` in the patterns view to edit these settings. After editing properties, you will have to hit the `c` key to _launch_ these changes. More about that later!
|
||||||
|
|
||||||
### Follow Up
|
### Follow Up
|
||||||
|
|
||||||
@@ -46,12 +46,12 @@ The follow-up action determines what happens when a pattern reaches the end of i
|
|||||||
Access the patterns view with `F2` (or `Ctrl+Up` from the sequencer). The view shows all banks and patterns in a grid. Indicators show pattern state:
|
Access the patterns view with `F2` (or `Ctrl+Up` from the sequencer). The view shows all banks and patterns in a grid. Indicators show pattern state:
|
||||||
|
|
||||||
- `>` Currently playing
|
- `>` Currently playing
|
||||||
- `+` Staged to play
|
- `+` Armed to play
|
||||||
- `-` Staged to stop
|
- `-` Armed to stop
|
||||||
- `M` Muted
|
- `M` Muted
|
||||||
- `S` Soloed
|
- `S` Soloed
|
||||||
|
|
||||||
It is quite essential for you to understand the stage / commit system in order to use patterns. Please read the next section carefully!
|
It is quite essential for you to understand the arm / launch system in order to use patterns. Please read the next section carefully!
|
||||||
|
|
||||||
### Keybindings
|
### Keybindings
|
||||||
|
|
||||||
@@ -59,13 +59,13 @@ It is quite essential for you to understand the stage / commit system in order t
|
|||||||
|-----|--------|
|
|-----|--------|
|
||||||
| `Arrows` | Navigate banks and patterns |
|
| `Arrows` | Navigate banks and patterns |
|
||||||
| `Enter` | Select and return to sequencer |
|
| `Enter` | Select and return to sequencer |
|
||||||
| `p` | Stage pattern to play/stop |
|
| `p` | Arm pattern to play/stop |
|
||||||
| `c` | Commit staged changes |
|
| `c` | Launch armed changes |
|
||||||
| `m` / `x` | Stage mute / solo toggle |
|
| `m` / `x` | Arm mute / solo toggle |
|
||||||
| `e` | Edit pattern properties |
|
| `e` | Edit pattern properties |
|
||||||
| `r` | Rename bank or pattern |
|
| `r` | Rename bank or pattern |
|
||||||
| `Ctrl+c` / `Ctrl+v` | Copy / Paste |
|
| `Ctrl+c` / `Ctrl+v` | Copy / Paste |
|
||||||
| `Delete` | Reset to empty pattern |
|
| `Delete` | Reset to empty pattern |
|
||||||
| `Esc` | Cancel staged changes |
|
| `Esc` | Cancel armed changes |
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
# Stage / Commit
|
# Arm / Launch
|
||||||
|
|
||||||
In Cagire, changes to playback happen in two steps. First you **stage**: you mark what you want to happen. Then you **commit**: you apply all staged changes at once. Nothing changes until you commit. It is simpler than it sounds.
|
In Cagire, changes to playback happen in two steps. First you **arm**: you mark what you want to happen. Then you **launch**: you apply all armed changes at once. Nothing changes until you launch. It is simpler than it sounds.
|
||||||
|
|
||||||
Say you want patterns `04` and `05` to start playing together. You stage both (`p` on each), then commit (`c`). Both start at the same time. Want to stop them later? Stage them again, commit again. That's it.
|
Say you want patterns `04` and `05` to start playing together. You arm both (`p` on each), then launch (`c`). Both start at the same time. Want to stop them later? Arm them again, launch again. That's it.
|
||||||
|
|
||||||
This two-step process exists for good reasons:
|
This two-step process exists for good reasons:
|
||||||
|
|
||||||
- **Multiple changes at once**: queue several patterns to start/stop, commit them together.
|
- **Multiple changes at once**: queue several patterns to start/stop, launch them together.
|
||||||
- **Clean timing**: all changes land on beat or bar boundaries, never mid-step.
|
- **Clean timing**: all changes land on beat or bar boundaries, never mid-step.
|
||||||
- **Safe preparation**: set up the next section while the current one keeps playing.
|
- **Safe preparation**: set up the next section while the current one keeps playing.
|
||||||
|
|
||||||
## Push changes, then apply
|
## Arm changes, then launch
|
||||||
|
|
||||||
Staging is an essential feature to understand to be effective when doing live performances:
|
Arming is an essential feature to understand to be effective when doing live performances:
|
||||||
|
|
||||||
1. Open the **Patterns** view (`F2` or `Ctrl+Up` from sequencer)
|
1. Open the **Patterns** view (`F2` or `Ctrl+Up` from sequencer)
|
||||||
2. Navigate to a pattern you wish to change/play
|
2. Navigate to a pattern you wish to change/play
|
||||||
3. Press `p` to stage it. The pending change is going to be displayed:
|
3. Press `p` to arm it. The pending change is going to be displayed:
|
||||||
- `+` (staged to play)
|
- `+` (armed to play)
|
||||||
- `-` (staged to stop)
|
- `-` (armed to stop)
|
||||||
- `m` (staged to mute)
|
- `m` (armed to mute)
|
||||||
- `s` (staged to solo)
|
- `s` (armed to solo)
|
||||||
- etc.
|
- etc.
|
||||||
4. Repeat for other patterns you want to change
|
4. Repeat for other patterns you want to change
|
||||||
5. Press `c` to commit all changes
|
5. Press `c` to launch all changes
|
||||||
6. Or press `Esc` to cancel
|
6. Or press `Esc` to cancel
|
||||||
|
|
||||||
You can also stage mute/solo changes:
|
You can also arm mute/solo changes:
|
||||||
|
|
||||||
- Press `m` to stage a mute toggle
|
- Press `m` to arm a mute toggle
|
||||||
- Press `x` to stage a solo toggle
|
- Press `x` to arm a solo toggle
|
||||||
- Press `Shift+m` to clear all mutes
|
- Press `Shift+m` to clear all mutes
|
||||||
- Press `Shift+x` to clear all solos
|
- Press `Shift+x` to clear all solos
|
||||||
|
|
||||||
@@ -41,16 +41,18 @@ It might wait for the next beat/bar boundary.
|
|||||||
| Indicator | Meaning |
|
| Indicator | Meaning |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| `>` | Currently playing |
|
| `>` | Currently playing |
|
||||||
| `+` | Staged to play |
|
| `+` | Armed to play |
|
||||||
| `-` | Staged to stop |
|
| `-` | Armed to stop |
|
||||||
| `M` | Muted |
|
| `M` | Muted |
|
||||||
| `S` | Soloed |
|
| `S` | Soloed |
|
||||||
|
|
||||||
A pattern can show combined indicators, e.g. `>` (playing) and `-` (staged to stop), or `>M` (playing and muted).
|
A pattern can show combined indicators, e.g. `>` (playing) and `-` (armed to stop), or `>M` (playing and muted).
|
||||||
|
|
||||||
|
Armed patterns blink to make pending changes impossible to miss.
|
||||||
|
|
||||||
## Quantization
|
## Quantization
|
||||||
|
|
||||||
Committed changes don't execute immediately. They wait for a quantization boundary:
|
Launched changes don't execute immediately. They wait for a quantization boundary:
|
||||||
|
|
||||||
| Setting | Behavior |
|
| Setting | Behavior |
|
||||||
|---------|----------|
|
|---------|----------|
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ impl App {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
self.ui
|
self.ui
|
||||||
.set_status(format!("{} props staged", bp_label(bank, pattern)));
|
.set_status(format!("{} props armed", bp_label(bank, pattern)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page navigation
|
// Page navigation
|
||||||
@@ -339,12 +339,6 @@ impl App {
|
|||||||
}
|
}
|
||||||
AppCommand::ToggleLiveKeysFill => self.live_keys.flip_fill(),
|
AppCommand::ToggleLiveKeysFill => self.live_keys.flip_fill(),
|
||||||
|
|
||||||
// Panel
|
|
||||||
AppCommand::ClosePanel => {
|
|
||||||
self.panel.visible = false;
|
|
||||||
self.panel.focus = crate::state::PanelFocus::Main;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct navigation (mouse)
|
// Direct navigation (mouse)
|
||||||
AppCommand::GoToStep(step) => {
|
AppCommand::GoToStep(step) => {
|
||||||
let len = self.current_edit_pattern().length;
|
let len = self.current_edit_pattern().length;
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ use crate::model::{self, Bank, Dictionary, Pattern, Rng, ScriptEngine, Variables
|
|||||||
use crate::page::Page;
|
use crate::page::Page;
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
undo::UndoHistory, AudioSettings, EditorContext, LiveKeyState, Metrics, Modal,
|
undo::UndoHistory, AudioSettings, EditorContext, LiveKeyState, Metrics, Modal,
|
||||||
OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, PlaybackState,
|
OptionsState, PatternField, PatternPropsField, PatternsNav, PlaybackState,
|
||||||
ProjectState, ScriptEditorState, UiState,
|
ProjectState, SampleBrowserState, ScriptEditorState, UiState,
|
||||||
};
|
};
|
||||||
|
|
||||||
static COMPLETION_CANDIDATES: LazyLock<Vec<CompletionCandidate>> = LazyLock::new(|| {
|
static COMPLETION_CANDIDATES: LazyLock<Arc<[CompletionCandidate]>> = LazyLock::new(|| {
|
||||||
model::WORDS
|
model::WORDS
|
||||||
.iter()
|
.iter()
|
||||||
.map(|w| CompletionCandidate {
|
.map(|w| CompletionCandidate {
|
||||||
@@ -66,7 +66,7 @@ pub struct App {
|
|||||||
|
|
||||||
pub audio: AudioSettings,
|
pub audio: AudioSettings,
|
||||||
pub options: OptionsState,
|
pub options: OptionsState,
|
||||||
pub panel: PanelState,
|
pub sample_browser: Option<SampleBrowserState>,
|
||||||
pub midi: MidiState,
|
pub midi: MidiState,
|
||||||
pub plugin_mode: bool,
|
pub plugin_mode: bool,
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ impl App {
|
|||||||
AudioSettings::default()
|
AudioSettings::default()
|
||||||
},
|
},
|
||||||
options: OptionsState::default(),
|
options: OptionsState::default(),
|
||||||
panel: PanelState::default(),
|
sample_browser: None,
|
||||||
midi: MidiState::new(),
|
midi: MidiState::new(),
|
||||||
plugin_mode,
|
plugin_mode,
|
||||||
}
|
}
|
||||||
@@ -213,6 +213,9 @@ impl App {
|
|||||||
if self.ui.modal != Modal::None {
|
if self.ui.modal != Modal::None {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if crate::model::onboarding::for_page(self.page).is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let name = self.page.name();
|
let name = self.page.name();
|
||||||
if self.ui.onboarding_dismissed.iter().any(|d| d == name) {
|
if self.ui.onboarding_dismissed.iter().any(|d| d == name) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Forth script compilation, evaluation, and editor ↔ step synchronization.
|
//! Forth script compilation, evaluation, and editor ↔ step synchronization.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crossbeam_channel::Sender;
|
use crossbeam_channel::Sender;
|
||||||
|
|
||||||
use crate::engine::LinkState;
|
use crate::engine::LinkState;
|
||||||
@@ -55,7 +57,7 @@ impl App {
|
|||||||
script.lines().map(String::from).collect()
|
script.lines().map(String::from).collect()
|
||||||
};
|
};
|
||||||
self.editor_ctx.editor.set_content(lines);
|
self.editor_ctx.editor.set_content(lines);
|
||||||
self.editor_ctx.editor.set_candidates(COMPLETION_CANDIDATES.clone());
|
self.editor_ctx.editor.set_candidates(Arc::clone(&COMPLETION_CANDIDATES));
|
||||||
self.editor_ctx
|
self.editor_ctx
|
||||||
.editor
|
.editor
|
||||||
.set_completion_enabled(self.ui.show_completion);
|
.set_completion_enabled(self.ui.show_completion);
|
||||||
@@ -87,7 +89,7 @@ impl App {
|
|||||||
prelude.lines().map(String::from).collect()
|
prelude.lines().map(String::from).collect()
|
||||||
};
|
};
|
||||||
self.editor_ctx.editor.set_content(lines);
|
self.editor_ctx.editor.set_content(lines);
|
||||||
self.editor_ctx.editor.set_candidates(COMPLETION_CANDIDATES.clone());
|
self.editor_ctx.editor.set_candidates(Arc::clone(&COMPLETION_CANDIDATES));
|
||||||
self.editor_ctx
|
self.editor_ctx
|
||||||
.editor
|
.editor
|
||||||
.set_completion_enabled(self.ui.show_completion);
|
.set_completion_enabled(self.ui.show_completion);
|
||||||
@@ -190,7 +192,7 @@ impl App {
|
|||||||
script.lines().map(String::from).collect()
|
script.lines().map(String::from).collect()
|
||||||
};
|
};
|
||||||
self.script_editor.editor.set_content(lines);
|
self.script_editor.editor.set_content(lines);
|
||||||
self.script_editor.editor.set_candidates(COMPLETION_CANDIDATES.clone());
|
self.script_editor.editor.set_candidates(Arc::clone(&COMPLETION_CANDIDATES));
|
||||||
self.script_editor
|
self.script_editor
|
||||||
.editor
|
.editor
|
||||||
.set_completion_enabled(self.ui.show_completion);
|
.set_completion_enabled(self.ui.show_completion);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ impl App {
|
|||||||
if let Some(idx) = existing {
|
if let Some(idx) = existing {
|
||||||
self.playback.staged_changes.remove(idx);
|
self.playback.staged_changes.remove(idx);
|
||||||
self.ui
|
self.ui
|
||||||
.set_status(format!("{} unstaged", bp_label(bank, pattern)));
|
.set_status(format!("{} disarmed", bp_label(bank, pattern)));
|
||||||
} else if is_playing {
|
} else if is_playing {
|
||||||
self.playback.staged_changes.push(StagedChange {
|
self.playback.staged_changes.push(StagedChange {
|
||||||
change: PatternChange::Stop { bank, pattern },
|
change: PatternChange::Stop { bank, pattern },
|
||||||
@@ -32,7 +32,7 @@ impl App {
|
|||||||
sync_mode: pattern_data.sync_mode,
|
sync_mode: pattern_data.sync_mode,
|
||||||
});
|
});
|
||||||
self.ui
|
self.ui
|
||||||
.set_status(format!("{} staged to stop", bp_label(bank, pattern)));
|
.set_status(format!("{} armed to stop", bp_label(bank, pattern)));
|
||||||
} else {
|
} else {
|
||||||
self.playback.staged_changes.push(StagedChange {
|
self.playback.staged_changes.push(StagedChange {
|
||||||
change: PatternChange::Start { bank, pattern },
|
change: PatternChange::Start { bank, pattern },
|
||||||
@@ -40,7 +40,7 @@ impl App {
|
|||||||
sync_mode: pattern_data.sync_mode,
|
sync_mode: pattern_data.sync_mode,
|
||||||
});
|
});
|
||||||
self.ui
|
self.ui
|
||||||
.set_status(format!("{} staged to play", bp_label(bank, pattern)));
|
.set_status(format!("{} armed to play", bp_label(bank, pattern)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ impl App {
|
|||||||
let prop_count = self.playback.staged_prop_changes.len();
|
let prop_count = self.playback.staged_prop_changes.len();
|
||||||
|
|
||||||
if pattern_count == 0 && mute_count == 0 && prop_count == 0 {
|
if pattern_count == 0 && mute_count == 0 && prop_count == 0 {
|
||||||
self.ui.set_status("No changes to commit".to_string());
|
self.ui.set_status("No changes to launch".to_string());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let total = pattern_count + mute_count + prop_count;
|
let total = pattern_count + mute_count + prop_count;
|
||||||
let status = format!("Committed {total} changes");
|
let status = format!("Launched {total} changes");
|
||||||
self.ui.set_status(status);
|
self.ui.set_status(status);
|
||||||
|
|
||||||
mute_changed
|
mute_changed
|
||||||
@@ -110,7 +110,7 @@ impl App {
|
|||||||
self.playback.staged_prop_changes.clear();
|
self.playback.staged_prop_changes.clear();
|
||||||
|
|
||||||
let total = pattern_count + mute_count + prop_count;
|
let total = pattern_count + mute_count + prop_count;
|
||||||
let status = format!("Cleared {total} staged changes");
|
let status = format!("Cleared {total} armed changes");
|
||||||
self.ui.set_status(status);
|
self.ui.set_status(status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,9 +232,6 @@ pub enum AppCommand {
|
|||||||
// Live keys
|
// Live keys
|
||||||
ToggleLiveKeysFill,
|
ToggleLiveKeysFill,
|
||||||
|
|
||||||
// Panel
|
|
||||||
ClosePanel,
|
|
||||||
|
|
||||||
// Direct navigation (mouse)
|
// Direct navigation (mouse)
|
||||||
GoToStep(usize),
|
GoToStep(usize),
|
||||||
PatternsSelectBank(usize),
|
PatternsSelectBank(usize),
|
||||||
|
|||||||
@@ -413,9 +413,15 @@ pub fn build_stream(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
let device_lost = Arc::clone(&device_lost);
|
let device_lost = Arc::clone(&device_lost);
|
||||||
move |err| {
|
move |err: cpal::StreamError| {
|
||||||
eprintln!("input stream error: {err}");
|
eprintln!("input stream error: {err}");
|
||||||
device_lost.store(true, Ordering::Release);
|
match err {
|
||||||
|
cpal::StreamError::DeviceNotAvailable
|
||||||
|
| cpal::StreamError::StreamInvalidated => {
|
||||||
|
device_lost.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
@@ -528,9 +534,15 @@ pub fn build_stream(
|
|||||||
let _ = fft_producer.try_push(mono);
|
let _ = fft_producer.try_push(mono);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
move |err| {
|
move |err: cpal::StreamError| {
|
||||||
let _ = error_tx.try_send(format!("stream error: {err}"));
|
let _ = error_tx.try_send(format!("stream error: {err}"));
|
||||||
device_lost.store(true, Ordering::Release);
|
match err {
|
||||||
|
cpal::StreamError::DeviceNotAvailable
|
||||||
|
| cpal::StreamError::StreamInvalidated => {
|
||||||
|
device_lost.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -555,7 +555,7 @@ pub struct SequencerState {
|
|||||||
pattern_cache: PatternCache,
|
pattern_cache: PatternCache,
|
||||||
pending_updates: HashMap<(usize, usize), PatternSnapshot>,
|
pending_updates: HashMap<(usize, usize), PatternSnapshot>,
|
||||||
runs_counter: RunsCounter,
|
runs_counter: RunsCounter,
|
||||||
step_traces: StepTracesMap,
|
step_traces: Arc<StepTracesMap>,
|
||||||
event_count: usize,
|
event_count: usize,
|
||||||
script_engine: ScriptEngine,
|
script_engine: ScriptEngine,
|
||||||
variables: Variables,
|
variables: Variables,
|
||||||
@@ -593,7 +593,7 @@ impl SequencerState {
|
|||||||
pattern_cache: PatternCache::new(),
|
pattern_cache: PatternCache::new(),
|
||||||
pending_updates: HashMap::new(),
|
pending_updates: HashMap::new(),
|
||||||
runs_counter: RunsCounter::new(),
|
runs_counter: RunsCounter::new(),
|
||||||
step_traces: HashMap::new(),
|
step_traces: Arc::new(HashMap::new()),
|
||||||
event_count: 0,
|
event_count: 0,
|
||||||
script_engine,
|
script_engine,
|
||||||
variables,
|
variables,
|
||||||
@@ -713,7 +713,7 @@ impl SequencerState {
|
|||||||
self.audio_state.active_patterns.clear();
|
self.audio_state.active_patterns.clear();
|
||||||
self.audio_state.pending_starts.clear();
|
self.audio_state.pending_starts.clear();
|
||||||
self.audio_state.pending_stops.clear();
|
self.audio_state.pending_stops.clear();
|
||||||
self.step_traces.clear();
|
self.step_traces = Arc::new(HashMap::new());
|
||||||
self.runs_counter.counts.clear();
|
self.runs_counter.counts.clear();
|
||||||
self.audio_state.flush_midi_notes = true;
|
self.audio_state.flush_midi_notes = true;
|
||||||
}
|
}
|
||||||
@@ -731,7 +731,7 @@ impl SequencerState {
|
|||||||
self.speed_overrides.clear();
|
self.speed_overrides.clear();
|
||||||
self.script_engine.clear_global_params();
|
self.script_engine.clear_global_params();
|
||||||
self.runs_counter.counts.clear();
|
self.runs_counter.counts.clear();
|
||||||
self.step_traces.clear();
|
self.step_traces = Arc::new(HashMap::new());
|
||||||
self.audio_state.flush_midi_notes = true;
|
self.audio_state.flush_midi_notes = true;
|
||||||
}
|
}
|
||||||
SeqCommand::ResetScriptState => {
|
SeqCommand::ResetScriptState => {
|
||||||
@@ -811,7 +811,7 @@ impl SequencerState {
|
|||||||
fn tick_paused(&mut self) -> TickOutput {
|
fn tick_paused(&mut self) -> TickOutput {
|
||||||
for pending in self.audio_state.pending_stops.drain(..) {
|
for pending in self.audio_state.pending_stops.drain(..) {
|
||||||
self.audio_state.active_patterns.remove(&pending.id);
|
self.audio_state.active_patterns.remove(&pending.id);
|
||||||
self.step_traces.retain(|&(bank, pattern, _), _| {
|
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
|
||||||
bank != pending.id.bank || pattern != pending.id.pattern
|
bank != pending.id.bank || pattern != pending.id.pattern
|
||||||
});
|
});
|
||||||
let key = (pending.id.bank, pending.id.pattern);
|
let key = (pending.id.bank, pending.id.pattern);
|
||||||
@@ -894,7 +894,7 @@ impl SequencerState {
|
|||||||
for pending in &self.audio_state.pending_stops {
|
for pending in &self.audio_state.pending_stops {
|
||||||
if check_quantization_boundary(pending.quantization, beat, prev_beat, quantum) {
|
if check_quantization_boundary(pending.quantization, beat, prev_beat, quantum) {
|
||||||
self.audio_state.active_patterns.remove(&pending.id);
|
self.audio_state.active_patterns.remove(&pending.id);
|
||||||
self.step_traces.retain(|&(bank, pattern, _), _| {
|
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
|
||||||
bank != pending.id.bank || pattern != pending.id.pattern
|
bank != pending.id.bank || pattern != pending.id.pattern
|
||||||
});
|
});
|
||||||
// Flush pending update so cache stays current for future launches
|
// Flush pending update so cache stays current for future launches
|
||||||
@@ -1015,7 +1015,7 @@ impl SequencerState {
|
|||||||
.script_engine
|
.script_engine
|
||||||
.evaluate_with_trace(script, &ctx, &mut trace)
|
.evaluate_with_trace(script, &ctx, &mut trace)
|
||||||
{
|
{
|
||||||
self.step_traces.insert(
|
Arc::make_mut(&mut self.step_traces).insert(
|
||||||
(active.bank, active.pattern, source_idx),
|
(active.bank, active.pattern, source_idx),
|
||||||
std::mem::take(&mut trace),
|
std::mem::take(&mut trace),
|
||||||
);
|
);
|
||||||
@@ -1229,7 +1229,7 @@ impl SequencerState {
|
|||||||
last_step_beat: a.last_step_beat,
|
last_step_beat: a.last_step_beat,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
step_traces: Arc::new(self.step_traces.clone()),
|
step_traces: Arc::clone(&self.step_traces),
|
||||||
event_count: self.event_count,
|
event_count: self.event_count,
|
||||||
tempo: self.last_tempo,
|
tempo: self.last_tempo,
|
||||||
beat: self.last_beat,
|
beat: self.last_beat,
|
||||||
|
|||||||
@@ -267,7 +267,8 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
}
|
}
|
||||||
KeyCode::Char('A') if ctx.app.audio.section == EngineSection::Samples => {
|
KeyCode::Char('A') if ctx.app.audio.section == EngineSection::Samples => {
|
||||||
use crate::state::file_browser::FileBrowserState;
|
use crate::state::file_browser::FileBrowserState;
|
||||||
let state = FileBrowserState::new_load(String::new());
|
let mut state = FileBrowserState::new_load(String::new());
|
||||||
|
state.compute_audio_counts();
|
||||||
ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(Box::new(state))));
|
ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(Box::new(state))));
|
||||||
}
|
}
|
||||||
KeyCode::Char('D') => {
|
KeyCode::Char('D') => {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ use std::sync::atomic::Ordering;
|
|||||||
|
|
||||||
use super::{InputContext, InputResult};
|
use super::{InputContext, InputResult};
|
||||||
use crate::commands::AppCommand;
|
use crate::commands::AppCommand;
|
||||||
|
use crate::page::Page;
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
ConfirmAction, CyclicEnum, EuclideanField, Modal, PanelFocus, PatternField, RenameTarget,
|
ConfirmAction, CyclicEnum, EuclideanField, Modal, PatternField, RenameTarget,
|
||||||
SampleBrowserState, SidePanel,
|
SampleBrowserState,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputResult {
|
pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputResult {
|
||||||
@@ -13,15 +14,11 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
|
|||||||
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Tab => {
|
KeyCode::Tab => {
|
||||||
if ctx.app.panel.visible {
|
if ctx.app.sample_browser.is_none() {
|
||||||
ctx.app.panel.visible = false;
|
ctx.app.sample_browser =
|
||||||
ctx.app.panel.focus = PanelFocus::Main;
|
Some(SampleBrowserState::new(&ctx.app.audio.config.sample_paths));
|
||||||
} else {
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
ctx.dispatch(AppCommand::GoToPage(Page::SampleExplorer));
|
||||||
}
|
}
|
||||||
KeyCode::Char('q') if !ctx.app.plugin_mode => {
|
KeyCode::Char('q') if !ctx.app.plugin_mode => {
|
||||||
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
|
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
|
||||||
@@ -79,9 +76,6 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
|
|||||||
let current = format!("{:.1}", ctx.link.tempo());
|
let current = format!("{:.1}", ctx.link.tempo());
|
||||||
ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current)));
|
ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current)));
|
||||||
}
|
}
|
||||||
KeyCode::Char(':') => {
|
|
||||||
ctx.dispatch(AppCommand::OpenModal(Modal::JumpToStep(String::new())));
|
|
||||||
}
|
|
||||||
KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease),
|
KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease),
|
||||||
KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease),
|
KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease),
|
||||||
KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease),
|
KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease),
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ mod main_page;
|
|||||||
mod modal;
|
mod modal;
|
||||||
mod mouse;
|
mod mouse;
|
||||||
pub(crate) mod options_page;
|
pub(crate) mod options_page;
|
||||||
mod panel;
|
|
||||||
mod patterns_page;
|
mod patterns_page;
|
||||||
|
mod sample_explorer;
|
||||||
mod script_page;
|
mod script_page;
|
||||||
|
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
@@ -22,7 +22,7 @@ use crate::app::App;
|
|||||||
use crate::commands::AppCommand;
|
use crate::commands::AppCommand;
|
||||||
use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot};
|
use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot};
|
||||||
use crate::page::Page;
|
use crate::page::Page;
|
||||||
use crate::state::{MinimapMode, Modal, PanelFocus};
|
use crate::state::{MinimapMode, Modal};
|
||||||
|
|
||||||
pub enum InputResult {
|
pub enum InputResult {
|
||||||
Continue,
|
Continue,
|
||||||
@@ -106,10 +106,6 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
return InputResult::Continue;
|
return InputResult::Continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.app.panel.visible && ctx.app.panel.focus == PanelFocus::Side {
|
|
||||||
return panel::handle_panel_input(ctx, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
if alt {
|
if alt {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
@@ -179,6 +175,19 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
return InputResult::Continue;
|
return InputResult::Continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if key.code == KeyCode::Char(':') {
|
||||||
|
let in_search = ctx.app.ui.dict_search_active || ctx.app.ui.help_search_active;
|
||||||
|
let in_script = ctx.app.page == Page::Script && ctx.app.script_editor.focused;
|
||||||
|
if !in_search && !in_script {
|
||||||
|
ctx.dispatch(AppCommand::OpenModal(Modal::CommandPalette {
|
||||||
|
input: String::new(),
|
||||||
|
cursor: 0,
|
||||||
|
scroll: 0,
|
||||||
|
}));
|
||||||
|
return InputResult::Continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match ctx.app.page {
|
match ctx.app.page {
|
||||||
Page::Main => main_page::handle_main_page(ctx, key, ctrl),
|
Page::Main => main_page::handle_main_page(ctx, key, ctrl),
|
||||||
Page::Patterns => patterns_page::handle_patterns_page(ctx, key),
|
Page::Patterns => patterns_page::handle_patterns_page(ctx, key),
|
||||||
@@ -187,6 +196,7 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
Page::Help => help_page::handle_help_page(ctx, key),
|
Page::Help => help_page::handle_help_page(ctx, key),
|
||||||
Page::Dict => help_page::handle_dict_page(ctx, key),
|
Page::Dict => help_page::handle_dict_page(ctx, key),
|
||||||
Page::Script => script_page::handle_script_page(ctx, key),
|
Page::Script => script_page::handle_script_page(ctx, key),
|
||||||
|
Page::SampleExplorer => sample_explorer::handle_sample_explorer(ctx, key),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,49 @@ use crate::state::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||||
|
// Handle CommandPalette before the main match to avoid borrow conflicts
|
||||||
|
// (Enter needs &App for palette_entries while the match borrows &mut modal)
|
||||||
|
if let Modal::CommandPalette { input, cursor, scroll } = &mut ctx.app.ui.modal {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
input.push(c);
|
||||||
|
*cursor = 0;
|
||||||
|
*scroll = 0;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
input.pop();
|
||||||
|
*cursor = 0;
|
||||||
|
*scroll = 0;
|
||||||
|
}
|
||||||
|
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
*cursor = cursor.saturating_sub(5);
|
||||||
|
}
|
||||||
|
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
*cursor += 5;
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
*cursor = cursor.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
*cursor += 1;
|
||||||
|
}
|
||||||
|
KeyCode::PageUp => {
|
||||||
|
*cursor = cursor.saturating_sub(10);
|
||||||
|
}
|
||||||
|
KeyCode::PageDown => {
|
||||||
|
*cursor += 10;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let query = input.clone();
|
||||||
|
let cursor_val = *cursor;
|
||||||
|
handle_palette_enter(ctx, &query, cursor_val);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return InputResult::Continue;
|
||||||
|
}
|
||||||
|
|
||||||
match &mut ctx.app.ui.modal {
|
match &mut ctx.app.ui.modal {
|
||||||
Modal::Confirm { action, selected } => {
|
Modal::Confirm { action, selected } => {
|
||||||
let confirmed = *selected;
|
let confirmed = *selected;
|
||||||
@@ -179,22 +222,6 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
KeyCode::Char(c) => input.push(c),
|
KeyCode::Char(c) => input.push(c),
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Modal::JumpToStep(input) => match key.code {
|
|
||||||
KeyCode::Enter => {
|
|
||||||
if let Ok(step) = input.parse::<usize>() {
|
|
||||||
if step > 0 {
|
|
||||||
ctx.dispatch(AppCommand::GoToStep(step - 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.dispatch(AppCommand::CloseModal);
|
|
||||||
}
|
|
||||||
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
input.pop();
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) if c.is_ascii_digit() => input.push(c),
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
Modal::SetTempo(input) => match key.code {
|
Modal::SetTempo(input) => match key.code {
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
if let Ok(tempo) = input.parse::<f64>() {
|
if let Ok(tempo) = input.parse::<f64>() {
|
||||||
@@ -222,8 +249,12 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
Some(state.current_dir().join(&entry.name))
|
Some(state.current_dir().join(&entry.name))
|
||||||
} else if entry.is_dir {
|
} else if entry.is_dir {
|
||||||
state.enter_selected();
|
state.enter_selected();
|
||||||
|
state.compute_audio_counts();
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
ctx.dispatch(AppCommand::SetStatus(
|
||||||
|
"Select a directory, not a file".into(),
|
||||||
|
));
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -261,15 +292,16 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
||||||
KeyCode::Tab => state.autocomplete(),
|
KeyCode::Tab => { state.autocomplete(); state.compute_audio_counts(); }
|
||||||
KeyCode::Left => state.go_up(),
|
KeyCode::Left => { state.go_up(); state.compute_audio_counts(); }
|
||||||
KeyCode::Right => state.enter_selected(),
|
KeyCode::Right => { state.enter_selected(); state.compute_audio_counts(); }
|
||||||
KeyCode::Up => state.select_prev(14),
|
KeyCode::Up => state.select_prev(16),
|
||||||
KeyCode::Down => state.select_next(14),
|
KeyCode::Down => state.select_next(16),
|
||||||
KeyCode::Backspace => state.backspace(),
|
KeyCode::Backspace => { state.backspace(); state.compute_audio_counts(); }
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
state.input.push(c);
|
state.input.push(c);
|
||||||
state.refresh_entries();
|
state.refresh_entries();
|
||||||
|
state.compute_audio_counts();
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
@@ -514,7 +546,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Modal::KeybindingsHelp { scroll } => {
|
Modal::KeybindingsHelp { scroll } => {
|
||||||
let bindings_count = crate::views::keybindings::bindings_for(ctx.app.page, ctx.app.plugin_mode).len();
|
let bindings_count = crate::model::palette::bindings_for(ctx.app.page, ctx.app.plugin_mode).len();
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc | KeyCode::Char('?') => ctx.dispatch(AppCommand::CloseModal),
|
KeyCode::Esc | KeyCode::Char('?') => ctx.dispatch(AppCommand::CloseModal),
|
||||||
KeyCode::Up | KeyCode::Char('k') => {
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
@@ -636,6 +668,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
_ => ctx.dispatch(AppCommand::CloseModal),
|
_ => ctx.dispatch(AppCommand::CloseModal),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Modal::CommandPalette { .. } => unreachable!(),
|
||||||
Modal::None => unreachable!(),
|
Modal::None => unreachable!(),
|
||||||
}
|
}
|
||||||
InputResult::Continue
|
InputResult::Continue
|
||||||
@@ -670,6 +703,97 @@ fn execute_confirm(ctx: &mut InputContext, action: &ConfirmAction) -> InputResul
|
|||||||
InputResult::Continue
|
InputResult::Continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_palette_enter(ctx: &mut InputContext, query: &str, cursor: usize) {
|
||||||
|
let page = ctx.app.page;
|
||||||
|
let plugin_mode = ctx.app.plugin_mode;
|
||||||
|
|
||||||
|
// Numeric input on Main page → jump to step
|
||||||
|
if page == crate::page::Page::Main
|
||||||
|
&& !query.is_empty()
|
||||||
|
&& query.chars().all(|c| c.is_ascii_digit())
|
||||||
|
{
|
||||||
|
if let Ok(step) = query.parse::<usize>() {
|
||||||
|
if step > 0 {
|
||||||
|
ctx.dispatch(AppCommand::GoToStep(step - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.dispatch(AppCommand::CloseModal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = crate::model::palette::palette_entries(query, plugin_mode, ctx.app);
|
||||||
|
if let Some(entry) = entries.get(cursor) {
|
||||||
|
ctx.dispatch(AppCommand::CloseModal);
|
||||||
|
execute_palette_entry(ctx, entry);
|
||||||
|
} else {
|
||||||
|
ctx.dispatch(AppCommand::CloseModal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_palette_entry(
|
||||||
|
ctx: &mut InputContext,
|
||||||
|
entry: &crate::model::palette::CommandEntry,
|
||||||
|
) {
|
||||||
|
use crate::model::palette::PaletteAction;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
match &entry.action {
|
||||||
|
Some(PaletteAction::Resolve(f)) => {
|
||||||
|
if let Some(cmd) = f(ctx.app) {
|
||||||
|
ctx.dispatch(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(PaletteAction::Save) => super::open_save(ctx),
|
||||||
|
Some(PaletteAction::Load) => super::open_load(ctx),
|
||||||
|
Some(PaletteAction::TogglePlaying) => {
|
||||||
|
ctx.dispatch(AppCommand::TogglePlaying);
|
||||||
|
ctx.playing
|
||||||
|
.store(ctx.app.playback.playing, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Some(PaletteAction::MuteToggle) => {
|
||||||
|
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
|
||||||
|
ctx.app.playback.toggle_mute(bank, pattern);
|
||||||
|
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
||||||
|
}
|
||||||
|
Some(PaletteAction::SoloToggle) => {
|
||||||
|
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
|
||||||
|
ctx.app.playback.toggle_solo(bank, pattern);
|
||||||
|
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
||||||
|
}
|
||||||
|
Some(PaletteAction::ClearMutes) => {
|
||||||
|
ctx.dispatch(AppCommand::ClearMutes);
|
||||||
|
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
||||||
|
}
|
||||||
|
Some(PaletteAction::ClearSolos) => {
|
||||||
|
ctx.dispatch(AppCommand::ClearSolos);
|
||||||
|
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
||||||
|
}
|
||||||
|
Some(PaletteAction::Hush) => {
|
||||||
|
let _ = ctx
|
||||||
|
.audio_tx
|
||||||
|
.load()
|
||||||
|
.send(crate::engine::AudioCommand::Hush);
|
||||||
|
}
|
||||||
|
Some(PaletteAction::Panic) => {
|
||||||
|
let _ = ctx
|
||||||
|
.audio_tx
|
||||||
|
.load()
|
||||||
|
.send(crate::engine::AudioCommand::Panic);
|
||||||
|
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
|
||||||
|
}
|
||||||
|
Some(PaletteAction::TestTone) => {
|
||||||
|
let _ = ctx
|
||||||
|
.audio_tx
|
||||||
|
.load()
|
||||||
|
.send(crate::engine::AudioCommand::Evaluate {
|
||||||
|
cmd: "/sound/sine/dur/0.5/decay/0.2".into(),
|
||||||
|
time: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn rename_command(target: &RenameTarget, name: Option<String>) -> AppCommand {
|
fn rename_command(target: &RenameTarget, name: Option<String>) -> AppCommand {
|
||||||
match target {
|
match target {
|
||||||
RenameTarget::Bank { bank } => AppCommand::RenameBank { bank: *bank, name },
|
RenameTarget::Bank { bank } => AppCommand::RenameBank { bank: *bank, name },
|
||||||
|
|||||||
@@ -212,6 +212,15 @@ fn handle_scroll(ctx: &mut InputContext, col: u16, row: u16, term: Rect, up: boo
|
|||||||
}
|
}
|
||||||
|
|
||||||
match ctx.app.page {
|
match ctx.app.page {
|
||||||
|
Page::SampleExplorer => {
|
||||||
|
if let Some(state) = &mut ctx.app.sample_browser {
|
||||||
|
if up {
|
||||||
|
state.move_up();
|
||||||
|
} else {
|
||||||
|
state.move_down();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Page::Main => {
|
Page::Main => {
|
||||||
if up {
|
if up {
|
||||||
ctx.dispatch(AppCommand::StepUp);
|
ctx.dispatch(AppCommand::StepUp);
|
||||||
@@ -347,6 +356,7 @@ fn handle_footer_click(ctx: &mut InputContext, col: u16, row: u16, footer: Rect)
|
|||||||
Page::Help => " HELP ",
|
Page::Help => " HELP ",
|
||||||
Page::Dict => " DICT ",
|
Page::Dict => " DICT ",
|
||||||
Page::Script => " SCRIPT ",
|
Page::Script => " SCRIPT ",
|
||||||
|
Page::SampleExplorer => " SAMPLES ",
|
||||||
};
|
};
|
||||||
let badge_end = block_inner.x + badge_text.len() as u16;
|
let badge_end = block_inner.x + badge_text.len() as u16;
|
||||||
if col < badge_end {
|
if col < badge_end {
|
||||||
@@ -357,37 +367,19 @@ fn handle_footer_click(ctx: &mut InputContext, col: u16, row: u16, footer: Rect)
|
|||||||
// --- Body ---
|
// --- Body ---
|
||||||
|
|
||||||
fn handle_body_click(ctx: &mut InputContext, col: u16, row: u16, body: Rect, kind: ClickKind) {
|
fn handle_body_click(ctx: &mut InputContext, col: u16, row: u16, body: Rect, kind: ClickKind) {
|
||||||
// Account for side panel splitting
|
if !contains(body, col, row) {
|
||||||
let page_area = if ctx.app.panel.visible && ctx.app.panel.side.is_some() {
|
|
||||||
if body.width >= 120 {
|
|
||||||
let panel_width = body.width * 35 / 100;
|
|
||||||
let [main, _side] =
|
|
||||||
Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)])
|
|
||||||
.areas(body);
|
|
||||||
main
|
|
||||||
} else {
|
|
||||||
let panel_height = body.height * 40 / 100;
|
|
||||||
let [main, _side] =
|
|
||||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)])
|
|
||||||
.areas(body);
|
|
||||||
main
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
body
|
|
||||||
};
|
|
||||||
|
|
||||||
if !contains(page_area, col, row) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
match ctx.app.page {
|
match ctx.app.page {
|
||||||
Page::Main => handle_main_click(ctx, col, row, page_area, kind),
|
Page::Main => handle_main_click(ctx, col, row, body, kind),
|
||||||
Page::Patterns => handle_patterns_click(ctx, col, row, page_area, kind),
|
Page::Patterns => handle_patterns_click(ctx, col, row, body, kind),
|
||||||
Page::Help => handle_help_click(ctx, col, row, page_area),
|
Page::Help => handle_help_click(ctx, col, row, body),
|
||||||
Page::Dict => handle_dict_click(ctx, col, row, page_area),
|
Page::Dict => handle_dict_click(ctx, col, row, body),
|
||||||
Page::Options => handle_options_click(ctx, col, row, page_area),
|
Page::Options => handle_options_click(ctx, col, row, body),
|
||||||
Page::Engine => handle_engine_click(ctx, col, row, page_area, kind),
|
Page::Engine => handle_engine_click(ctx, col, row, body, kind),
|
||||||
Page::Script => handle_script_click(ctx, col, row, page_area),
|
Page::Script => handle_script_click(ctx, col, row, body),
|
||||||
|
Page::SampleExplorer => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -873,20 +865,7 @@ fn handle_script_editor_drag(ctx: &mut InputContext, col: u16, row: u16, term: R
|
|||||||
if ctx.app.script_editor.mouse_selecting {
|
if ctx.app.script_editor.mouse_selecting {
|
||||||
let padded = padded(term);
|
let padded = padded(term);
|
||||||
let (_header, body, _footer) = top_level_layout(padded);
|
let (_header, body, _footer) = top_level_layout(padded);
|
||||||
let page_area = if ctx.app.panel.visible && ctx.app.panel.side.is_some() {
|
handle_script_editor_mouse(ctx, col, row, body, true);
|
||||||
if body.width >= 120 {
|
|
||||||
let panel_width = body.width * 35 / 100;
|
|
||||||
Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)])
|
|
||||||
.split(body)[0]
|
|
||||||
} else {
|
|
||||||
let panel_height = body.height * 40 / 100;
|
|
||||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)])
|
|
||||||
.split(body)[0]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
body
|
|
||||||
};
|
|
||||||
handle_script_editor_mouse(ctx, col, row, page_area, true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,10 +962,12 @@ fn handle_modal_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
|
|||||||
Modal::PatternProps { .. } => (50, 18),
|
Modal::PatternProps { .. } => (50, 18),
|
||||||
Modal::EuclideanDistribution { .. } => (50, 11),
|
Modal::EuclideanDistribution { .. } => (50, 11),
|
||||||
Modal::Onboarding { .. } => (57, 20),
|
Modal::Onboarding { .. } => (57, 20),
|
||||||
Modal::FileBrowser(_) | Modal::AddSamplePath(_) => (60, 18),
|
Modal::FileBrowser(_) => (60, 18),
|
||||||
|
Modal::AddSamplePath(_) => (70, 20),
|
||||||
Modal::Rename { .. } => (40, 5),
|
Modal::Rename { .. } => (40, 5),
|
||||||
Modal::SetPattern { .. } | Modal::SetScript { .. } => (45, 5),
|
Modal::SetPattern { .. } | Modal::SetScript { .. } => (45, 5),
|
||||||
Modal::SetTempo(_) | Modal::JumpToStep(_) => (30, 5),
|
Modal::SetTempo(_) => (30, 5),
|
||||||
|
Modal::CommandPalette { .. } => (55, 20),
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
let modal_area = centered_rect(term, w, h);
|
let modal_area = centered_rect(term, w, h);
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|||||||
use super::{InputContext, InputResult};
|
use super::{InputContext, InputResult};
|
||||||
use crate::commands::AppCommand;
|
use crate::commands::AppCommand;
|
||||||
use crate::engine::AudioCommand;
|
use crate::engine::AudioCommand;
|
||||||
use crate::state::SidePanel;
|
use crate::page::Page;
|
||||||
use cagire_ratatui::TreeLineKind;
|
use cagire_ratatui::TreeLineKind;
|
||||||
|
|
||||||
pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
pub(super) fn handle_sample_explorer(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||||
let state = match &mut ctx.app.panel.side {
|
let state = match &mut ctx.app.sample_browser {
|
||||||
Some(SidePanel::SampleBrowser(s)) => s,
|
Some(s) => s,
|
||||||
None => return InputResult::Continue,
|
None => return InputResult::Continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
}
|
}
|
||||||
KeyCode::Down => {
|
KeyCode::Down => {
|
||||||
for _ in 0..10 {
|
for _ in 0..10 {
|
||||||
state.move_down(30);
|
state.move_down();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -49,7 +49,7 @@ pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
} else {
|
} else {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Up | KeyCode::Char('k') => state.move_up(),
|
KeyCode::Up | KeyCode::Char('k') => state.move_up(),
|
||||||
KeyCode::Down | KeyCode::Char('j') => state.move_down(30),
|
KeyCode::Down | KeyCode::Char('j') => state.move_down(),
|
||||||
KeyCode::PageUp => {
|
KeyCode::PageUp => {
|
||||||
for _ in 0..20 {
|
for _ in 0..20 {
|
||||||
state.move_up();
|
state.move_up();
|
||||||
@@ -57,7 +57,7 @@ pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
}
|
}
|
||||||
KeyCode::PageDown => {
|
KeyCode::PageDown => {
|
||||||
for _ in 0..20 {
|
for _ in 0..20 {
|
||||||
state.move_down(30);
|
state.move_down();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Enter | KeyCode::Right => {
|
KeyCode::Enter | KeyCode::Right => {
|
||||||
@@ -71,6 +71,10 @@ pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
.audio_tx
|
.audio_tx
|
||||||
.load()
|
.load()
|
||||||
.send(AudioCommand::Evaluate { cmd, time: None });
|
.send(AudioCommand::Evaluate { cmd, time: None });
|
||||||
|
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||||
|
"\u{25B8} {}/{}",
|
||||||
|
folder, entry.label
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
_ => state.toggle_expand(),
|
_ => state.toggle_expand(),
|
||||||
}
|
}
|
||||||
@@ -82,11 +86,11 @@ pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
if state.has_filter() {
|
if state.has_filter() {
|
||||||
state.clear_filter();
|
state.clear_filter();
|
||||||
} else {
|
} else {
|
||||||
ctx.dispatch(AppCommand::ClosePanel);
|
ctx.dispatch(AppCommand::GoToPage(Page::Main));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Tab => {
|
KeyCode::Tab => {
|
||||||
ctx.dispatch(AppCommand::ClosePanel);
|
ctx.dispatch(AppCommand::GoToPage(Page::Main));
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -355,10 +355,16 @@ fn main() -> io::Result<()> {
|
|||||||
let elapsed = last_frame.elapsed();
|
let elapsed = last_frame.elapsed();
|
||||||
last_frame = Instant::now();
|
last_frame = Instant::now();
|
||||||
|
|
||||||
|
let has_armed = app.playback.has_armed();
|
||||||
|
if has_armed {
|
||||||
|
let rate = std::f32::consts::TAU; // 1 Hz full cycle
|
||||||
|
app.ui.pulse_phase = (app.ui.pulse_phase + elapsed.as_secs_f32() * rate) % std::f32::consts::TAU;
|
||||||
|
}
|
||||||
let effects_active = app.ui.effects.borrow().is_running()
|
let effects_active = app.ui.effects.borrow().is_running()
|
||||||
|| app.ui.modal_fx.borrow().is_some()
|
|| app.ui.modal_fx.borrow().is_some()
|
||||||
|| app.ui.title_fx.borrow().is_some()
|
|| app.ui.title_fx.borrow().is_some()
|
||||||
|| app.ui.nav_fx.borrow().is_some();
|
|| app.ui.nav_fx.borrow().is_some()
|
||||||
|
|| has_armed;
|
||||||
let cursor_pulse = app.page == page::Page::Main && !app.ui.performance_mode && !app.playback.playing;
|
let cursor_pulse = app.page == page::Page::Main && !app.ui.performance_mode && !app.playback.playing;
|
||||||
let audio_cooldown = !app.playback.playing && last_stop_time.elapsed() < Duration::from_secs(1);
|
let audio_cooldown = !app.playback.playing && last_stop_time.elapsed() < Duration::from_secs(1);
|
||||||
if app.playback.playing || had_event || app.ui.show_title || effects_active || app.ui.show_minimap() || cursor_pulse || audio_cooldown {
|
if app.playback.playing || had_event || app.ui.show_title || effects_active || app.ui.show_minimap() || cursor_pulse || audio_cooldown {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod categories;
|
|||||||
pub mod demos;
|
pub mod demos;
|
||||||
pub mod docs;
|
pub mod docs;
|
||||||
pub mod onboarding;
|
pub mod onboarding;
|
||||||
|
pub mod palette;
|
||||||
mod script;
|
mod script;
|
||||||
|
|
||||||
pub use cagire_forth::{
|
pub use cagire_forth::{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ pub fn for_page(page: Page) -> &'static [(&'static str, &'static [(&'static str,
|
|||||||
match page {
|
match page {
|
||||||
Page::Main => &[
|
Page::Main => &[
|
||||||
(
|
(
|
||||||
"The step sequencer grid. Each cell is a Forth script that produces sound when evaluated. During playback, active steps run left-to-right, top-to-bottom. Toggle steps on/off with t to build your pattern. The left panel shows playing patterns, the right side shows VU meters.",
|
"The step sequencer grid. Each cell is a Forth script that produces sound when evaluated. During playback, active steps run left-to-right, top-to-bottom. Toggle steps on/off with t to build your pattern.",
|
||||||
&[
|
&[
|
||||||
("Arrows", "navigate grid"),
|
("Arrows", "navigate grid"),
|
||||||
("Space", "play / stop"),
|
("Space", "play / stop"),
|
||||||
@@ -30,12 +30,12 @@ pub fn for_page(page: Page) -> &'static [(&'static str, &'static [(&'static str,
|
|||||||
],
|
],
|
||||||
Page::Patterns => &[
|
Page::Patterns => &[
|
||||||
(
|
(
|
||||||
"Organize your project into banks and patterns. The left column lists 32 banks, the right shows patterns in the selected bank. The bottom strip previews steps and pattern properties. Stage patterns to play or stop, then commit to apply all changes at once.",
|
"Organize your project into banks and patterns. The left column lists 32 banks, the right shows patterns in the selected bank. The bottom strip previews steps and pattern properties. Arm patterns to play or stop, then launch to apply all changes at once.",
|
||||||
&[
|
&[
|
||||||
("Arrows", "navigate"),
|
("Arrows", "navigate"),
|
||||||
("Enter", "open in sequencer"),
|
("Enter", "open in sequencer"),
|
||||||
("Space", "stage play/stop"),
|
("p", "arm play/stop"),
|
||||||
("c", "commit changes"),
|
("c", "launch changes"),
|
||||||
("r", "rename"),
|
("r", "rename"),
|
||||||
("e", "properties"),
|
("e", "properties"),
|
||||||
("?", "all keys"),
|
("?", "all keys"),
|
||||||
@@ -44,8 +44,8 @@ pub fn for_page(page: Page) -> &'static [(&'static str, &'static [(&'static str,
|
|||||||
(
|
(
|
||||||
"Mute and solo patterns to control the mix. Use euclidean distribution to generate rhythmic patterns from a single step. Select multiple patterns with Shift for bulk operations.",
|
"Mute and solo patterns to control the mix. Use euclidean distribution to generate rhythmic patterns from a single step. Select multiple patterns with Shift for bulk operations.",
|
||||||
&[
|
&[
|
||||||
("m", "stage mute"),
|
("m", "arm mute"),
|
||||||
("s", "stage solo"),
|
("s", "arm solo"),
|
||||||
("E", "euclidean"),
|
("E", "euclidean"),
|
||||||
("Shift+↑↓", "select range"),
|
("Shift+↑↓", "select range"),
|
||||||
("y", "copy"),
|
("y", "copy"),
|
||||||
@@ -101,5 +101,6 @@ pub fn for_page(page: Page) -> &'static [(&'static str, &'static [(&'static str,
|
|||||||
("Ctrl+S", "toggle stack preview"),
|
("Ctrl+S", "toggle stack preview"),
|
||||||
],
|
],
|
||||||
)],
|
)],
|
||||||
|
Page::SampleExplorer => &[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1320
src/model/palette.rs
Normal file
1320
src/model/palette.rs
Normal file
File diff suppressed because it is too large
Load Diff
19
src/page.rs
19
src/page.rs
@@ -8,10 +8,11 @@ pub enum Page {
|
|||||||
Dict,
|
Dict,
|
||||||
Options,
|
Options,
|
||||||
Script,
|
Script,
|
||||||
|
SampleExplorer,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Page {
|
impl Page {
|
||||||
/// All pages for iteration (grid pages only — Script excluded)
|
/// All pages for iteration (grid pages only — Script and SampleExplorer excluded)
|
||||||
pub const ALL: &'static [Page] = &[
|
pub const ALL: &'static [Page] = &[
|
||||||
Page::Main,
|
Page::Main,
|
||||||
Page::Patterns,
|
Page::Patterns,
|
||||||
@@ -29,7 +30,7 @@ impl Page {
|
|||||||
/// col 0 col 1 col 2
|
/// col 0 col 1 col 2
|
||||||
/// row 0 Dict Patterns Options
|
/// row 0 Dict Patterns Options
|
||||||
/// row 1 Help Sequencer Engine
|
/// row 1 Help Sequencer Engine
|
||||||
/// Script lives outside the grid at (1, 2)
|
/// Script and SampleExplorer live outside the grid at (1, 2)
|
||||||
pub const fn grid_pos(self) -> (i8, i8) {
|
pub const fn grid_pos(self) -> (i8, i8) {
|
||||||
match self {
|
match self {
|
||||||
Page::Dict => (0, 0),
|
Page::Dict => (0, 0),
|
||||||
@@ -38,7 +39,7 @@ impl Page {
|
|||||||
Page::Main => (1, 1),
|
Page::Main => (1, 1),
|
||||||
Page::Options => (2, 0),
|
Page::Options => (2, 0),
|
||||||
Page::Engine => (2, 1),
|
Page::Engine => (2, 1),
|
||||||
Page::Script => (1, 2),
|
Page::Script | Page::SampleExplorer => (1, 2),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +58,12 @@ impl Page {
|
|||||||
Page::Dict => "Dict",
|
Page::Dict => "Dict",
|
||||||
Page::Options => "Options",
|
Page::Options => "Options",
|
||||||
Page::Script => "Script",
|
Page::Script => "Script",
|
||||||
|
Page::SampleExplorer => "Samples",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn left(&mut self) {
|
pub fn left(&mut self) {
|
||||||
if *self == Page::Script {
|
if matches!(*self, Page::Script | Page::SampleExplorer) {
|
||||||
*self = Page::Help;
|
*self = Page::Help;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -76,7 +78,7 @@ impl Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn right(&mut self) {
|
pub fn right(&mut self) {
|
||||||
if *self == Page::Script {
|
if matches!(*self, Page::Script | Page::SampleExplorer) {
|
||||||
*self = Page::Engine;
|
*self = Page::Engine;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -91,7 +93,7 @@ impl Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn up(&mut self) {
|
pub fn up(&mut self) {
|
||||||
if *self == Page::Script {
|
if matches!(*self, Page::Script | Page::SampleExplorer) {
|
||||||
*self = Page::Main;
|
*self = Page::Main;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -115,13 +117,12 @@ impl Page {
|
|||||||
Page::Engine => Some(14), // "Introduction" (Audio Engine)
|
Page::Engine => Some(14), // "Introduction" (Audio Engine)
|
||||||
Page::Help => Some(0), // "Welcome"
|
Page::Help => Some(0), // "Welcome"
|
||||||
Page::Dict => Some(7), // "About Forth"
|
Page::Dict => Some(7), // "About Forth"
|
||||||
Page::Options => None,
|
Page::Options | Page::Script | Page::SampleExplorer => None,
|
||||||
Page::Script => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this page appears in the navigation minimap grid.
|
/// Whether this page appears in the navigation minimap grid.
|
||||||
pub const fn visible_in_minimap(self) -> bool {
|
pub const fn visible_in_minimap(self) -> bool {
|
||||||
!matches!(self, Page::Script)
|
!matches!(self, Page::Script | Page::SampleExplorer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub struct FileBrowserState {
|
|||||||
pub entries: Vec<DirEntry>,
|
pub entries: Vec<DirEntry>,
|
||||||
pub selected: usize,
|
pub selected: usize,
|
||||||
pub scroll_offset: usize,
|
pub scroll_offset: usize,
|
||||||
|
pub audio_counts: Vec<Option<usize>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileBrowserState {
|
impl FileBrowserState {
|
||||||
@@ -37,6 +38,7 @@ impl FileBrowserState {
|
|||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
selected: 0,
|
selected: 0,
|
||||||
scroll_offset: 0,
|
scroll_offset: 0,
|
||||||
|
audio_counts: Vec::new(),
|
||||||
};
|
};
|
||||||
state.refresh_entries();
|
state.refresh_entries();
|
||||||
state
|
state
|
||||||
@@ -49,6 +51,7 @@ impl FileBrowserState {
|
|||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
selected: 0,
|
selected: 0,
|
||||||
scroll_offset: 0,
|
scroll_offset: 0,
|
||||||
|
audio_counts: Vec::new(),
|
||||||
};
|
};
|
||||||
state.refresh_entries();
|
state.refresh_entries();
|
||||||
state
|
state
|
||||||
@@ -119,10 +122,27 @@ impl FileBrowserState {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.entries = entries;
|
self.entries = entries;
|
||||||
|
self.audio_counts = Vec::new();
|
||||||
self.selected = 0;
|
self.selected = 0;
|
||||||
self.scroll_offset = 0;
|
self.scroll_offset = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn compute_audio_counts(&mut self) {
|
||||||
|
let dir = self.current_dir();
|
||||||
|
self.audio_counts = self
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
if !entry.is_dir || entry.name == ".." {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = dir.join(&entry.name);
|
||||||
|
let count = count_audio_files(&path);
|
||||||
|
if count > 0 { Some(count) } else { None }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn autocomplete(&mut self) {
|
pub fn autocomplete(&mut self) {
|
||||||
let real_entries: Vec<&DirEntry> =
|
let real_entries: Vec<&DirEntry> =
|
||||||
self.entries.iter().filter(|e| e.name != "..").collect();
|
self.entries.iter().filter(|e| e.name != "..").collect();
|
||||||
@@ -249,6 +269,23 @@ fn ensure_parent_dirs(path: &Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn count_audio_files(path: &Path) -> usize {
|
||||||
|
let Ok(read_dir) = fs::read_dir(path) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
read_dir
|
||||||
|
.flatten()
|
||||||
|
.filter(|e| {
|
||||||
|
let name = e.file_name();
|
||||||
|
let name = name.to_string_lossy();
|
||||||
|
matches!(
|
||||||
|
name.rsplit('.').next().map(|ext| ext.to_lowercase()).as_deref(),
|
||||||
|
Some("wav" | "flac" | "ogg" | "aiff" | "aif" | "mp3")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
fn longest_common_prefix(entries: &[&DirEntry]) -> String {
|
fn longest_common_prefix(entries: &[&DirEntry]) -> String {
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
return String::new();
|
return String::new();
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ pub mod file_browser;
|
|||||||
pub mod live_keys;
|
pub mod live_keys;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
pub mod options;
|
pub mod options;
|
||||||
pub mod panel;
|
|
||||||
pub mod patterns_nav;
|
pub mod patterns_nav;
|
||||||
pub mod playback;
|
pub mod playback;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
@@ -38,7 +37,6 @@ pub use editor::{
|
|||||||
pub use live_keys::LiveKeyState;
|
pub use live_keys::LiveKeyState;
|
||||||
pub use modal::{ConfirmAction, Modal, RenameTarget};
|
pub use modal::{ConfirmAction, Modal, RenameTarget};
|
||||||
pub use options::{OptionsFocus, OptionsState};
|
pub use options::{OptionsFocus, OptionsState};
|
||||||
pub use panel::{PanelFocus, PanelState, SidePanel};
|
|
||||||
pub use patterns_nav::{PatternsColumn, PatternsNav};
|
pub use patterns_nav::{PatternsColumn, PatternsNav};
|
||||||
pub use playback::{PlaybackState, StagedChange, StagedMuteChange, StagedPropChange};
|
pub use playback::{PlaybackState, StagedChange, StagedMuteChange, StagedPropChange};
|
||||||
pub use project::ProjectState;
|
pub use project::ProjectState;
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ pub enum Modal {
|
|||||||
input: String,
|
input: String,
|
||||||
},
|
},
|
||||||
SetTempo(String),
|
SetTempo(String),
|
||||||
JumpToStep(String),
|
|
||||||
AddSamplePath(Box<FileBrowserState>),
|
AddSamplePath(Box<FileBrowserState>),
|
||||||
Editor,
|
Editor,
|
||||||
PatternProps {
|
PatternProps {
|
||||||
@@ -102,4 +101,9 @@ pub enum Modal {
|
|||||||
rotation: String,
|
rotation: String,
|
||||||
},
|
},
|
||||||
Onboarding { page: usize },
|
Onboarding { page: usize },
|
||||||
|
CommandPalette {
|
||||||
|
input: String,
|
||||||
|
cursor: usize,
|
||||||
|
scroll: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
use super::sample_browser::SampleBrowserState;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum PanelFocus {
|
|
||||||
#[default]
|
|
||||||
Main,
|
|
||||||
Side,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum SidePanel {
|
|
||||||
SampleBrowser(SampleBrowserState),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PanelState {
|
|
||||||
pub side: Option<SidePanel>,
|
|
||||||
pub focus: PanelFocus,
|
|
||||||
pub visible: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for PanelState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
side: None,
|
|
||||||
focus: PanelFocus::Main,
|
|
||||||
visible: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -130,6 +130,34 @@ impl PlaybackState {
|
|||||||
self.soloed.contains(&(bank, pattern))
|
self.soloed.contains(&(bank, pattern))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn has_armed(&self) -> bool {
|
||||||
|
!self.staged_changes.is_empty()
|
||||||
|
|| !self.staged_mute_changes.is_empty()
|
||||||
|
|| !self.staged_prop_changes.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn armed_summary(&self) -> Option<String> {
|
||||||
|
let play = self.staged_changes.iter().filter(|c| matches!(c.change, PatternChange::Start { .. })).count();
|
||||||
|
let stop = self.staged_changes.iter().filter(|c| matches!(c.change, PatternChange::Stop { .. })).count();
|
||||||
|
let mute = self.staged_mute_changes.iter().filter(|c| matches!(c, StagedMuteChange::ToggleMute { .. })).count();
|
||||||
|
let solo = self.staged_mute_changes.iter().filter(|c| matches!(c, StagedMuteChange::ToggleSolo { .. })).count();
|
||||||
|
let props = self.staged_prop_changes.len();
|
||||||
|
|
||||||
|
let parts: Vec<String> = [
|
||||||
|
(play, "play"),
|
||||||
|
(stop, "stop"),
|
||||||
|
(mute, "mute"),
|
||||||
|
(solo, "solo"),
|
||||||
|
(props, "props"),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(n, _)| *n > 0)
|
||||||
|
.map(|(n, label)| format!("{n} {label}"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if parts.is_empty() { None } else { Some(parts.join(", ")) }
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_effectively_muted(&self, bank: usize, pattern: usize) -> bool {
|
pub fn is_effectively_muted(&self, bank: usize, pattern: usize) -> bool {
|
||||||
if self.muted.contains(&(bank, pattern)) {
|
if self.muted.contains(&(bank, pattern)) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::cell::Cell;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -62,12 +63,19 @@ impl SampleNode {
|
|||||||
SampleNode::Folder { expanded, .. } => TreeLineKind::Folder { expanded: *expanded },
|
SampleNode::Folder { expanded, .. } => TreeLineKind::Folder { expanded: *expanded },
|
||||||
SampleNode::File { .. } => TreeLineKind::File,
|
SampleNode::File { .. } => TreeLineKind::File,
|
||||||
};
|
};
|
||||||
|
let child_count = match self {
|
||||||
|
SampleNode::Root { children, .. } | SampleNode::Folder { children, .. } => {
|
||||||
|
children.iter().filter(|c| matches!(c, SampleNode::File { .. })).count()
|
||||||
|
}
|
||||||
|
SampleNode::File { .. } => 0,
|
||||||
|
};
|
||||||
out.push(TreeLine {
|
out.push(TreeLine {
|
||||||
depth,
|
depth,
|
||||||
kind,
|
kind,
|
||||||
label: self.label().to_string(),
|
label: self.label().to_string(),
|
||||||
folder: parent_folder.to_string(),
|
folder: parent_folder.to_string(),
|
||||||
index: file_index,
|
index: file_index,
|
||||||
|
child_count,
|
||||||
});
|
});
|
||||||
if self.expanded() {
|
if self.expanded() {
|
||||||
let folder_name = self.label();
|
let folder_name = self.label();
|
||||||
@@ -321,6 +329,7 @@ impl SampleTree {
|
|||||||
expanded,
|
expanded,
|
||||||
} if name == target_name => {
|
} if name == target_name => {
|
||||||
let show_children = !collapsed && *expanded;
|
let show_children = !collapsed && *expanded;
|
||||||
|
let file_count = children.iter().filter(|c| matches!(c, SampleNode::File { .. })).count();
|
||||||
out.push(TreeLine {
|
out.push(TreeLine {
|
||||||
depth: 0,
|
depth: 0,
|
||||||
kind: TreeLineKind::Folder {
|
kind: TreeLineKind::Folder {
|
||||||
@@ -329,6 +338,7 @@ impl SampleTree {
|
|||||||
label: name.clone(),
|
label: name.clone(),
|
||||||
folder: String::new(),
|
folder: String::new(),
|
||||||
index: 0,
|
index: 0,
|
||||||
|
child_count: file_count,
|
||||||
});
|
});
|
||||||
if show_children {
|
if show_children {
|
||||||
let mut idx = 0;
|
let mut idx = 0;
|
||||||
@@ -340,6 +350,7 @@ impl SampleTree {
|
|||||||
label: fname.clone(),
|
label: fname.clone(),
|
||||||
folder: name.clone(),
|
folder: name.clone(),
|
||||||
index: idx,
|
index: idx,
|
||||||
|
child_count: 0,
|
||||||
});
|
});
|
||||||
idx += 1;
|
idx += 1;
|
||||||
}
|
}
|
||||||
@@ -362,6 +373,7 @@ pub struct SampleBrowserState {
|
|||||||
pub scroll_offset: usize,
|
pub scroll_offset: usize,
|
||||||
pub search_query: String,
|
pub search_query: String,
|
||||||
pub search_active: bool,
|
pub search_active: bool,
|
||||||
|
pub visible_height: Cell<usize>,
|
||||||
filter: Option<Vec<String>>,
|
filter: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +385,7 @@ impl SampleBrowserState {
|
|||||||
scroll_offset: 0,
|
scroll_offset: 0,
|
||||||
search_query: String::new(),
|
search_query: String::new(),
|
||||||
search_active: false,
|
search_active: false,
|
||||||
|
visible_height: Cell::new(20),
|
||||||
filter: None,
|
filter: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,6 +440,10 @@ impl SampleBrowserState {
|
|||||||
if self.scroll_offset > self.cursor {
|
if self.scroll_offset > self.cursor {
|
||||||
self.scroll_offset = self.cursor;
|
self.scroll_offset = self.cursor;
|
||||||
}
|
}
|
||||||
|
let vh = self.visible_height.get();
|
||||||
|
if vh > 0 && self.cursor >= self.scroll_offset + vh {
|
||||||
|
self.scroll_offset = self.cursor - vh + 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_up(&mut self) {
|
pub fn move_up(&mut self) {
|
||||||
@@ -438,15 +455,16 @@ impl SampleBrowserState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_down(&mut self, visible_height: usize) {
|
pub fn move_down(&mut self) {
|
||||||
let count = self.visible_count();
|
let count = self.visible_count();
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if self.cursor + 1 < count {
|
if self.cursor + 1 < count {
|
||||||
self.cursor += 1;
|
self.cursor += 1;
|
||||||
if self.cursor >= self.scroll_offset + visible_height {
|
let vh = self.visible_height.get();
|
||||||
self.scroll_offset = self.cursor - visible_height + 1;
|
if vh > 0 && self.cursor >= self.scroll_offset + vh {
|
||||||
|
self.scroll_offset = self.cursor - vh + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ pub struct UiState {
|
|||||||
pub demo_index: usize,
|
pub demo_index: usize,
|
||||||
pub nav_indicator_until: Option<Instant>,
|
pub nav_indicator_until: Option<Instant>,
|
||||||
pub nav_fx: RefCell<Option<Effect>>,
|
pub nav_fx: RefCell<Option<Effect>>,
|
||||||
|
pub pulse_phase: f32,
|
||||||
pub last_click: Option<(Instant, u16, u16)>,
|
pub last_click: Option<(Instant, u16, u16)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +143,7 @@ impl Default for UiState {
|
|||||||
demo_index: 0,
|
demo_index: 0,
|
||||||
nav_indicator_until: None,
|
nav_indicator_until: None,
|
||||||
nav_fx: RefCell::new(None),
|
nav_fx: RefCell::new(None),
|
||||||
|
pulse_phase: 0.0,
|
||||||
last_click: None,
|
last_click: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
use crate::page::Page;
|
|
||||||
|
|
||||||
pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'static str, &'static str)> {
|
|
||||||
let mut bindings = vec![
|
|
||||||
("F1–F7", "Go to view", "Dict/Patterns/Options/Help/Sequencer/Engine/Script"),
|
|
||||||
("Ctrl+←→↑↓", "Navigate", "Switch between adjacent views"),
|
|
||||||
];
|
|
||||||
if !plugin_mode {
|
|
||||||
bindings.push(("q", "Quit", "Quit application"));
|
|
||||||
}
|
|
||||||
bindings.extend([
|
|
||||||
("s", "Save", "Save project"),
|
|
||||||
("l", "Load", "Load project"),
|
|
||||||
("?", "Keybindings", "Show this help"),
|
|
||||||
("F12", "Restart", "Full restart from step 0"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Page-specific bindings
|
|
||||||
match page {
|
|
||||||
Page::Main => {
|
|
||||||
if !plugin_mode {
|
|
||||||
bindings.push(("Space", "Play/Stop", "Toggle playback"));
|
|
||||||
}
|
|
||||||
bindings.push(("Alt+↑↓", "Pattern", "Previous/next pattern"));
|
|
||||||
bindings.push(("Alt+←→", "Bank", "Previous/next bank"));
|
|
||||||
bindings.push(("←→↑↓", "Navigate", "Move cursor between steps"));
|
|
||||||
bindings.push(("Shift+←→↑↓", "Select", "Extend selection"));
|
|
||||||
bindings.push(("Esc", "Clear", "Clear selection"));
|
|
||||||
bindings.push(("Enter", "Edit", "Open step editor"));
|
|
||||||
bindings.push(("t", "Toggle", "Toggle selected steps"));
|
|
||||||
bindings.push(("p", "Prelude", "Edit prelude script"));
|
|
||||||
bindings.push(("Tab", "Samples", "Toggle sample browser"));
|
|
||||||
bindings.push(("Ctrl+C", "Copy", "Copy selected steps"));
|
|
||||||
bindings.push(("Ctrl+V", "Paste", "Paste steps"));
|
|
||||||
bindings.push(("Ctrl+B", "Link", "Paste as linked steps"));
|
|
||||||
bindings.push(("Ctrl+D", "Duplicate", "Duplicate selection"));
|
|
||||||
bindings.push(("Ctrl+H", "Harden", "Convert links to copies"));
|
|
||||||
bindings.push(("Del", "Delete", "Delete step(s)"));
|
|
||||||
bindings.push(("< >", "Length", "Decrease/increase pattern length"));
|
|
||||||
bindings.push(("[ ]", "Speed", "Decrease/increase pattern speed"));
|
|
||||||
if !plugin_mode {
|
|
||||||
bindings.push(("+ -", "Tempo", "Increase/decrease tempo"));
|
|
||||||
bindings.push(("T", "Set tempo", "Open tempo input"));
|
|
||||||
}
|
|
||||||
bindings.push(("L", "Set length", "Open length input"));
|
|
||||||
bindings.push(("S", "Set speed", "Open speed input"));
|
|
||||||
bindings.push(("f", "Fill", "Toggle fill mode (hold)"));
|
|
||||||
bindings.push(("r", "Rename", "Rename current step"));
|
|
||||||
bindings.push(("Ctrl+R", "Run", "Run step script immediately"));
|
|
||||||
bindings.push((":", "Jump", "Jump to step number"));
|
|
||||||
bindings.push(("e", "Euclidean", "Distribute linked steps using Euclidean rhythm"));
|
|
||||||
bindings.push(("m", "Mute", "Stage mute for current pattern"));
|
|
||||||
bindings.push(("x", "Solo", "Stage solo for current pattern"));
|
|
||||||
bindings.push(("M", "Clear mutes", "Clear all mutes"));
|
|
||||||
bindings.push(("X", "Clear solos", "Clear all solos"));
|
|
||||||
bindings.push(("d", "Eval prelude", "Re-evaluate prelude without editing"));
|
|
||||||
bindings.push(("g", "Share", "Export pattern to clipboard"));
|
|
||||||
bindings.push(("G", "Import", "Import pattern from clipboard"));
|
|
||||||
}
|
|
||||||
Page::Patterns => {
|
|
||||||
bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns"));
|
|
||||||
bindings.push(("Shift+↑↓", "Select", "Extend selection"));
|
|
||||||
bindings.push(("Alt+↑↓", "Shift", "Move patterns up/down"));
|
|
||||||
bindings.push(("Enter", "Select", "Select pattern for editing"));
|
|
||||||
if !plugin_mode {
|
|
||||||
bindings.push(("Space", "Play", "Toggle pattern playback"));
|
|
||||||
}
|
|
||||||
bindings.push(("Esc", "Back", "Clear staged or go back"));
|
|
||||||
bindings.push(("c", "Commit", "Commit staged changes"));
|
|
||||||
bindings.push(("p", "Stage play", "Stage pattern play toggle"));
|
|
||||||
bindings.push(("r", "Rename", "Rename bank/pattern"));
|
|
||||||
bindings.push(("d", "Describe", "Add description to pattern"));
|
|
||||||
bindings.push(("e", "Properties", "Edit pattern properties"));
|
|
||||||
bindings.push(("m", "Mute", "Stage mute for pattern"));
|
|
||||||
bindings.push(("x", "Solo", "Stage solo for pattern"));
|
|
||||||
bindings.push(("M", "Clear mutes", "Clear all mutes"));
|
|
||||||
bindings.push(("X", "Clear solos", "Clear all solos"));
|
|
||||||
bindings.push(("g", "Share", "Export bank or pattern to clipboard"));
|
|
||||||
bindings.push(("G", "Import", "Import bank or pattern from clipboard"));
|
|
||||||
bindings.push(("Ctrl+C", "Copy", "Copy bank/pattern"));
|
|
||||||
bindings.push(("Ctrl+V", "Paste", "Paste bank/pattern"));
|
|
||||||
bindings.push(("Del", "Reset", "Reset bank/pattern"));
|
|
||||||
bindings.push(("Ctrl+Z", "Undo", "Undo last action"));
|
|
||||||
bindings.push(("Ctrl+Shift+Z", "Redo", "Redo last action"));
|
|
||||||
}
|
|
||||||
Page::Engine => {
|
|
||||||
bindings.push(("Tab", "Section", "Next section"));
|
|
||||||
bindings.push(("Shift+Tab", "Section", "Previous section"));
|
|
||||||
bindings.push(("←→", "Switch", "Switch device type or adjust setting"));
|
|
||||||
bindings.push(("↑↓", "Navigate", "Navigate list items"));
|
|
||||||
bindings.push(("PgUp/Dn", "Page", "Page through device list"));
|
|
||||||
bindings.push(("Enter", "Select", "Select device"));
|
|
||||||
if !plugin_mode {
|
|
||||||
bindings.push(("R", "Restart", "Restart audio engine"));
|
|
||||||
}
|
|
||||||
bindings.push(("A", "Add path", "Add sample path"));
|
|
||||||
bindings.push(("D", "Refresh/Del", "Refresh devices or delete path"));
|
|
||||||
bindings.push(("h", "Hush", "Stop all sounds gracefully"));
|
|
||||||
bindings.push(("p", "Panic", "Stop all sounds immediately"));
|
|
||||||
bindings.push(("r", "Reset", "Reset peak voice counter"));
|
|
||||||
if !plugin_mode {
|
|
||||||
bindings.push(("t", "Test", "Play test tone"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Page::Options => {
|
|
||||||
bindings.push(("Tab", "Next", "Move to next option"));
|
|
||||||
bindings.push(("Shift+Tab", "Previous", "Move to previous option"));
|
|
||||||
bindings.push(("↑↓", "Navigate", "Navigate options"));
|
|
||||||
bindings.push(("←→", "Toggle", "Toggle or adjust option"));
|
|
||||||
if !plugin_mode {
|
|
||||||
bindings.push(("Space", "Play/Stop", "Toggle playback"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Page::Help => {
|
|
||||||
bindings.push(("↑↓ j/k", "Scroll", "Scroll content"));
|
|
||||||
bindings.push(("Tab", "Topic", "Next topic"));
|
|
||||||
bindings.push(("Shift+Tab", "Topic", "Previous topic"));
|
|
||||||
bindings.push(("PgUp/Dn", "Page", "Page scroll"));
|
|
||||||
bindings.push(("n", "Next code", "Jump to next code block"));
|
|
||||||
bindings.push(("p", "Prev code", "Jump to previous code block"));
|
|
||||||
bindings.push(("Enter", "Run code", "Execute focused code block"));
|
|
||||||
bindings.push(("/", "Search", "Activate search"));
|
|
||||||
bindings.push(("Esc", "Clear", "Clear search / deselect block"));
|
|
||||||
}
|
|
||||||
Page::Dict => {
|
|
||||||
bindings.push(("Tab", "Focus", "Toggle category/words focus"));
|
|
||||||
bindings.push(("↑↓ j/k", "Navigate", "Navigate items"));
|
|
||||||
bindings.push(("PgUp/Dn", "Page", "Page scroll"));
|
|
||||||
bindings.push(("/", "Search", "Activate search"));
|
|
||||||
bindings.push(("Ctrl+F", "Search", "Activate search"));
|
|
||||||
bindings.push(("Esc", "Clear", "Clear search"));
|
|
||||||
}
|
|
||||||
Page::Script => {
|
|
||||||
bindings.push(("Enter", "Focus", "Focus editor for typing"));
|
|
||||||
bindings.push(("Esc", "Unfocus", "Unfocus editor to use page keybindings"));
|
|
||||||
bindings.push(("Ctrl+E", "Evaluate", "Compile and check for errors (focused)"));
|
|
||||||
bindings.push(("S", "Set Speed", "Set script speed via text input (unfocused)"));
|
|
||||||
bindings.push(("L", "Set Length", "Set script length via text input (unfocused)"));
|
|
||||||
bindings.push(("Ctrl+S", "Stack", "Toggle stack preview (focused)"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bindings
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,11 @@ pub mod dict_view;
|
|||||||
pub mod engine_view;
|
pub mod engine_view;
|
||||||
pub mod help_view;
|
pub mod help_view;
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
pub mod keybindings;
|
|
||||||
pub mod main_view;
|
pub mod main_view;
|
||||||
pub mod options_view;
|
pub mod options_view;
|
||||||
pub mod patterns_view;
|
pub mod patterns_view;
|
||||||
mod render;
|
mod render;
|
||||||
|
pub mod sample_explorer_view;
|
||||||
pub mod script_view;
|
pub mod script_view;
|
||||||
pub mod title_view;
|
pub mod title_view;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
@@ -13,6 +13,20 @@ use crate::widgets::{render_scroll_indicators, IndicatorAlign};
|
|||||||
|
|
||||||
const MIN_ROW_HEIGHT: u16 = 1;
|
const MIN_ROW_HEIGHT: u16 = 1;
|
||||||
|
|
||||||
|
fn pulse_value(phase: f32) -> f32 {
|
||||||
|
phase.sin() * 0.5 + 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pulse_color(from: Color, to: Color, t: f32) -> Color {
|
||||||
|
match (from, to) {
|
||||||
|
(Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => {
|
||||||
|
let l = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t) as u8;
|
||||||
|
Color::Rgb(l(r1, r2), l(g1, g2), l(b1, b2))
|
||||||
|
}
|
||||||
|
_ => from,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Replaces the background color of spans beyond `filled_cols` with `unfilled_bg`.
|
/// Replaces the background color of spans beyond `filled_cols` with `unfilled_bg`.
|
||||||
fn apply_progress_bg(spans: Vec<Span<'_>>, filled_cols: usize, unfilled_bg: Color) -> Vec<Span<'_>> {
|
fn apply_progress_bg(spans: Vec<Span<'_>>, filled_cols: usize, unfilled_bg: Color) -> Vec<Span<'_>> {
|
||||||
let mut result = Vec::with_capacity(spans.len() + 1);
|
let mut result = Vec::with_capacity(spans.len() + 1);
|
||||||
@@ -54,7 +68,32 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area:
|
|||||||
Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)]).areas(bottom_area);
|
Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)]).areas(bottom_area);
|
||||||
|
|
||||||
render_banks(frame, app, snapshot, banks_area);
|
render_banks(frame, app, snapshot, banks_area);
|
||||||
render_patterns(frame, app, snapshot, patterns_area);
|
|
||||||
|
let armed_summary = app.playback.armed_summary();
|
||||||
|
let (patterns_main, launch_bar_area) = if armed_summary.is_some() {
|
||||||
|
let [main, bar] =
|
||||||
|
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(patterns_area);
|
||||||
|
(main, Some(bar))
|
||||||
|
} else {
|
||||||
|
(patterns_area, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
render_patterns(frame, app, snapshot, patterns_main);
|
||||||
|
|
||||||
|
if let (Some(bar_area), Some(summary)) = (launch_bar_area, armed_summary) {
|
||||||
|
let pulse = pulse_value(app.ui.pulse_phase);
|
||||||
|
let pulsed_fg = pulse_color(theme.list.staged_play_fg, theme.list.staged_play_bg, pulse * 0.6);
|
||||||
|
let text = format!("\u{25b6} {summary} \u{2014} c to launch");
|
||||||
|
let bar = Paragraph::new(text)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.style(
|
||||||
|
Style::new()
|
||||||
|
.fg(pulsed_fg)
|
||||||
|
.bg(theme.list.staged_play_bg)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
frame.render_widget(bar, bar_area);
|
||||||
|
}
|
||||||
|
|
||||||
let bank = app.patterns_nav.bank_cursor;
|
let bank = app.patterns_nav.bank_cursor;
|
||||||
let pattern_idx = app.patterns_nav.pattern_cursor;
|
let pattern_idx = app.patterns_nav.pattern_cursor;
|
||||||
@@ -82,6 +121,7 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area:
|
|||||||
|
|
||||||
fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||||
let theme = theme::get();
|
let theme = theme::get();
|
||||||
|
let pulse = pulse_value(app.ui.pulse_phase);
|
||||||
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Banks);
|
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Banks);
|
||||||
|
|
||||||
let border_color = if is_focused { theme.ui.header } else { theme.ui.border };
|
let border_color = if is_focused { theme.ui.header } else { theme.ui.border };
|
||||||
@@ -215,6 +255,12 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
|
|||||||
} else {
|
} else {
|
||||||
style
|
style
|
||||||
};
|
};
|
||||||
|
let style = if (is_staged || has_staged_mute_solo) && !is_cursor && !is_in_range {
|
||||||
|
let pulsed = pulse_color(fg, bg, pulse * 0.6);
|
||||||
|
style.fg(pulsed)
|
||||||
|
} else {
|
||||||
|
style
|
||||||
|
};
|
||||||
|
|
||||||
let bg_block = Block::default().style(Style::new().bg(bg));
|
let bg_block = Block::default().style(Style::new().bg(bg));
|
||||||
frame.render_widget(bg_block, row_area);
|
frame.render_widget(bg_block, row_area);
|
||||||
@@ -247,6 +293,7 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
|
|||||||
|
|
||||||
fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||||
use crate::model::PatternSpeed;
|
use crate::model::PatternSpeed;
|
||||||
|
let pulse = pulse_value(app.ui.pulse_phase);
|
||||||
|
|
||||||
let theme = theme::get();
|
let theme = theme::get();
|
||||||
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Patterns);
|
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Patterns);
|
||||||
@@ -312,8 +359,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
|
|
||||||
let cursor = app.patterns_nav.pattern_cursor;
|
let cursor = app.patterns_nav.pattern_cursor;
|
||||||
let available = inner.height as usize;
|
let available = inner.height as usize;
|
||||||
// Cursor row takes 2 lines (main + detail); account for 1 extra
|
let max_visible = available.max(1);
|
||||||
let max_visible = available.saturating_sub(1).max(1);
|
|
||||||
|
|
||||||
let scroll_offset = if MAX_PATTERNS <= max_visible {
|
let scroll_offset = if MAX_PATTERNS <= max_visible {
|
||||||
0
|
0
|
||||||
@@ -328,8 +374,6 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
let mut y = inner.y;
|
let mut y = inner.y;
|
||||||
for visible_idx in 0..visible_count {
|
for visible_idx in 0..visible_count {
|
||||||
let idx = scroll_offset + visible_idx;
|
let idx = scroll_offset + visible_idx;
|
||||||
let is_expanded = idx == cursor;
|
|
||||||
let row_h = if is_expanded { 2u16 } else { 1u16 };
|
|
||||||
if y >= inner.y + inner.height {
|
if y >= inner.y + inner.height {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -338,7 +382,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
x: inner.x,
|
x: inner.x,
|
||||||
y,
|
y,
|
||||||
width: inner.width,
|
width: inner.width,
|
||||||
height: row_h.min(inner.y + inner.height - y),
|
height: 1u16.min(inner.y + inner.height - y),
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_cursor = is_focused && idx == app.patterns_nav.pattern_cursor;
|
let is_cursor = is_focused && idx == app.patterns_nav.pattern_cursor;
|
||||||
@@ -424,21 +468,9 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
let base_style = Style::new().bg(bg).fg(fg);
|
let base_style = Style::new().bg(bg).fg(fg);
|
||||||
let bold_style = base_style.add_modifier(Modifier::BOLD);
|
let bold_style = base_style.add_modifier(Modifier::BOLD);
|
||||||
|
|
||||||
let content_area = if is_expanded {
|
let bg_block = Block::default().style(Style::new().bg(bg));
|
||||||
let border_color = if is_focused { theme.selection.cursor } else { theme.ui.unfocused };
|
frame.render_widget(bg_block, row_area);
|
||||||
let block = Block::default()
|
let content_area = row_area;
|
||||||
.borders(Borders::LEFT | Borders::RIGHT)
|
|
||||||
.border_type(BorderType::QuadrantOutside)
|
|
||||||
.border_style(Style::new().fg(border_color).bg(bg))
|
|
||||||
.style(Style::new().bg(bg));
|
|
||||||
let content = block.inner(row_area);
|
|
||||||
frame.render_widget(block, row_area);
|
|
||||||
content
|
|
||||||
} else {
|
|
||||||
let bg_block = Block::default().style(Style::new().bg(bg));
|
|
||||||
frame.render_widget(bg_block, row_area);
|
|
||||||
row_area
|
|
||||||
};
|
|
||||||
|
|
||||||
let text_area = Rect {
|
let text_area = Rect {
|
||||||
x: content_area.x,
|
x: content_area.x,
|
||||||
@@ -454,6 +486,14 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
};
|
};
|
||||||
let dim_style = base_style.remove_modifier(Modifier::BOLD);
|
let dim_style = base_style.remove_modifier(Modifier::BOLD);
|
||||||
|
|
||||||
|
let is_armed = is_staged_play || is_staged_stop || has_staged_mute || has_staged_solo || has_staged_props;
|
||||||
|
let (name_style, dim_style) = if is_armed && !is_cursor && !is_in_range {
|
||||||
|
let pulsed = pulse_color(fg, bg, pulse * 0.6);
|
||||||
|
(name_style.fg(pulsed), dim_style.fg(pulsed))
|
||||||
|
} else {
|
||||||
|
(name_style, dim_style)
|
||||||
|
};
|
||||||
|
|
||||||
let mut spans = vec![Span::styled(format!("{}{:02}", prefix, idx + 1), name_style)];
|
let mut spans = vec![Span::styled(format!("{}{:02}", prefix, idx + 1), name_style)];
|
||||||
if !name.is_empty() {
|
if !name.is_empty() {
|
||||||
spans.push(Span::styled(format!(" {name}"), name_style));
|
spans.push(Span::styled(format!(" {name}"), name_style));
|
||||||
@@ -466,16 +506,38 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
let props_indicator = if has_staged_props { "~" } else { "" };
|
let props_indicator = if has_staged_props { "~" } else { "" };
|
||||||
let right_info = if content_count > 0 {
|
let quant_sync = if is_selected {
|
||||||
format!("{props_indicator}{content_count}/{length}{speed_str}")
|
format!(
|
||||||
|
"{}:{} ",
|
||||||
|
pattern.quantization.short_label(),
|
||||||
|
pattern.sync_mode.short_label()
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("{props_indicator} {length}{speed_str}")
|
String::new()
|
||||||
|
};
|
||||||
|
let right_info = if content_count > 0 {
|
||||||
|
format!("{quant_sync}{props_indicator}{content_count}/{length}{speed_str}")
|
||||||
|
} else {
|
||||||
|
format!("{quant_sync}{props_indicator} {length}{speed_str}")
|
||||||
};
|
};
|
||||||
let left_width: usize = spans.iter().map(|s| s.content.chars().count()).sum();
|
let left_width: usize = spans.iter().map(|s| s.content.chars().count()).sum();
|
||||||
let right_width = right_info.chars().count();
|
let right_width = right_info.chars().count();
|
||||||
let padding = (text_area.width as usize).saturating_sub(left_width + right_width + 1);
|
let gap = (text_area.width as usize).saturating_sub(left_width + right_width + 1);
|
||||||
|
|
||||||
spans.push(Span::styled(" ".repeat(padding), dim_style));
|
if let Some(desc) = pattern.description.as_deref().filter(|d| !d.is_empty() && gap > 4) {
|
||||||
|
let budget = gap - 2;
|
||||||
|
let char_count = desc.chars().count();
|
||||||
|
if char_count <= budget {
|
||||||
|
spans.push(Span::styled(format!(" {desc}"), dim_style));
|
||||||
|
spans.push(Span::styled(" ".repeat(gap - char_count - 1), dim_style));
|
||||||
|
} else {
|
||||||
|
let truncated: String = desc.chars().take(budget - 1).collect();
|
||||||
|
spans.push(Span::styled(format!(" {truncated}\u{2026}"), dim_style));
|
||||||
|
spans.push(Span::styled(" ", dim_style));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
spans.push(Span::styled(" ".repeat(gap), dim_style));
|
||||||
|
}
|
||||||
spans.push(Span::styled(right_info, dim_style));
|
spans.push(Span::styled(right_info, dim_style));
|
||||||
|
|
||||||
let spans = if is_playing && !is_cursor && !is_in_range {
|
let spans = if is_playing && !is_cursor && !is_in_range {
|
||||||
@@ -488,52 +550,6 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
|
|
||||||
frame.render_widget(Paragraph::new(Line::from(spans)), text_area);
|
frame.render_widget(Paragraph::new(Line::from(spans)), text_area);
|
||||||
|
|
||||||
if is_expanded && content_area.height >= 2 {
|
|
||||||
let detail_area = Rect {
|
|
||||||
x: content_area.x,
|
|
||||||
y: content_area.y + 1,
|
|
||||||
width: content_area.width,
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let right_label = format!(
|
|
||||||
"{} · {}",
|
|
||||||
pattern.quantization.label(),
|
|
||||||
pattern.sync_mode.label()
|
|
||||||
);
|
|
||||||
let w = detail_area.width as usize;
|
|
||||||
let label = if let Some(desc) = &pattern.description {
|
|
||||||
let right_len = right_label.chars().count();
|
|
||||||
let max_desc = w.saturating_sub(right_len + 1);
|
|
||||||
let truncated: String = desc.chars().take(max_desc).collect();
|
|
||||||
let pad = w.saturating_sub(truncated.chars().count() + right_len);
|
|
||||||
format!("{truncated}{}{right_label}", " ".repeat(pad))
|
|
||||||
} else {
|
|
||||||
format!("{right_label:>w$}")
|
|
||||||
};
|
|
||||||
let padded_label = label;
|
|
||||||
|
|
||||||
let filled_width = if is_playing {
|
|
||||||
let ratio = snapshot.get_smooth_progress(bank, idx, length, speed.multiplier()).unwrap_or(0.0);
|
|
||||||
(ratio * detail_area.width as f64).min(detail_area.width as f64) as usize
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
let dim_fg = theme.ui.text_muted;
|
|
||||||
let progress_bg = theme.list.playing_bg;
|
|
||||||
let byte_offset = padded_label
|
|
||||||
.char_indices()
|
|
||||||
.nth(filled_width)
|
|
||||||
.map_or(padded_label.len(), |(i, _)| i);
|
|
||||||
let (left, right) = padded_label.split_at(byte_offset);
|
|
||||||
let detail_spans = vec![
|
|
||||||
Span::styled(left.to_string(), Style::new().bg(progress_bg).fg(dim_fg)),
|
|
||||||
Span::styled(right.to_string(), Style::new().bg(theme.ui.bg).fg(dim_fg)),
|
|
||||||
];
|
|
||||||
frame.render_widget(Paragraph::new(Line::from(detail_spans)), detail_area);
|
|
||||||
}
|
|
||||||
|
|
||||||
y += row_area.height;
|
y += row_area.height;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,19 +14,18 @@ use crate::engine::{LinkState, SequencerSnapshot};
|
|||||||
use crate::model::{ExecutionTrace, SourceSpan};
|
use crate::model::{ExecutionTrace, SourceSpan};
|
||||||
use crate::page::Page;
|
use crate::page::Page;
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
EditorTarget, EuclideanField, FlashKind, Modal, PanelFocus, PatternField, RenameTarget,
|
EditorTarget, EuclideanField, FlashKind, Modal, PatternField, RenameTarget,
|
||||||
SidePanel,
|
|
||||||
};
|
};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::views::highlight::{self, highlight_line_with_runtime};
|
use crate::views::highlight::{self, highlight_line_with_runtime};
|
||||||
use crate::widgets::{
|
use crate::widgets::{
|
||||||
hint_line, render_props_form, render_search_bar, ConfirmModal, ModalFrame, NavMinimap, NavTile,
|
hint_line, render_props_form, render_search_bar, ConfirmModal, ModalFrame, NavMinimap, NavTile,
|
||||||
SampleBrowser, TextInputModal,
|
TextInputModal,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
dict_view, engine_view, help_view, main_view, options_view, patterns_view, script_view,
|
dict_view, engine_view, help_view, main_view, options_view, patterns_view,
|
||||||
title_view,
|
sample_explorer_view, script_view, title_view,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn clip_span(span: SourceSpan, line_start: usize, line_len: usize) -> Option<SourceSpan> {
|
fn clip_span(span: SourceSpan, line_start: usize, line_len: usize) -> Option<SourceSpan> {
|
||||||
@@ -164,28 +163,15 @@ pub fn render(
|
|||||||
render_header(frame, app, link, snapshot, header_area);
|
render_header(frame, app, link, snapshot, header_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (page_area, panel_area) = if app.panel.visible && app.panel.side.is_some() {
|
|
||||||
let panel_width = body_area.width * 35 / 100;
|
|
||||||
let [main, side] =
|
|
||||||
Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)])
|
|
||||||
.areas(body_area);
|
|
||||||
(main, Some(side))
|
|
||||||
} else {
|
|
||||||
(body_area, None)
|
|
||||||
};
|
|
||||||
|
|
||||||
match app.page {
|
match app.page {
|
||||||
Page::Main => main_view::render(frame, app, snapshot, page_area),
|
Page::Main => main_view::render(frame, app, snapshot, body_area),
|
||||||
Page::Patterns => patterns_view::render(frame, app, snapshot, page_area),
|
Page::Patterns => patterns_view::render(frame, app, snapshot, body_area),
|
||||||
Page::Engine => engine_view::render(frame, app, link, page_area),
|
Page::Engine => engine_view::render(frame, app, link, body_area),
|
||||||
Page::Options => options_view::render(frame, app, page_area),
|
Page::Options => options_view::render(frame, app, body_area),
|
||||||
Page::Help => help_view::render(frame, app, page_area),
|
Page::Help => help_view::render(frame, app, body_area),
|
||||||
Page::Dict => dict_view::render(frame, app, page_area),
|
Page::Dict => dict_view::render(frame, app, body_area),
|
||||||
Page::Script => script_view::render(frame, app, snapshot, page_area),
|
Page::Script => script_view::render(frame, app, snapshot, body_area),
|
||||||
}
|
Page::SampleExplorer => sample_explorer_view::render(frame, app, body_area),
|
||||||
|
|
||||||
if let Some(side_area) = panel_area {
|
|
||||||
render_side_panel(frame, app, side_area);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !perf {
|
if !perf {
|
||||||
@@ -292,62 +278,6 @@ fn header_height(_width: u16) -> u16 {
|
|||||||
3
|
3
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_side_panel(frame: &mut Frame, app: &App, area: Rect) {
|
|
||||||
let focused = app.panel.focus == PanelFocus::Side;
|
|
||||||
match &app.panel.side {
|
|
||||||
Some(SidePanel::SampleBrowser(state)) => {
|
|
||||||
let [tree_area, preview_area] =
|
|
||||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(6)]).areas(area);
|
|
||||||
|
|
||||||
let entries = state.entries();
|
|
||||||
SampleBrowser::new(&entries, state.cursor)
|
|
||||||
.scroll_offset(state.scroll_offset)
|
|
||||||
.search(&state.search_query, state.search_active)
|
|
||||||
.focused(focused)
|
|
||||||
.render(frame, tree_area);
|
|
||||||
|
|
||||||
if let Some(sample) = state
|
|
||||||
.sample_key()
|
|
||||||
.and_then(|key| app.audio.sample_registry.as_ref()?.get(&key))
|
|
||||||
.filter(|s| s.frame_count >= s.total_frames)
|
|
||||||
{
|
|
||||||
use crate::widgets::Waveform;
|
|
||||||
use std::cell::RefCell;
|
|
||||||
thread_local! {
|
|
||||||
static MONO_BUF: RefCell<Vec<f32>> = const { RefCell::new(Vec::new()) };
|
|
||||||
}
|
|
||||||
|
|
||||||
let [wave_area, info_area] =
|
|
||||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)])
|
|
||||||
.areas(preview_area);
|
|
||||||
|
|
||||||
MONO_BUF.with(|buf| {
|
|
||||||
let mut buf = buf.borrow_mut();
|
|
||||||
let channels = sample.channels as usize;
|
|
||||||
let frame_count = sample.frame_count as usize;
|
|
||||||
buf.clear();
|
|
||||||
buf.reserve(frame_count);
|
|
||||||
for i in 0..frame_count {
|
|
||||||
buf.push(sample.frames[i * channels]);
|
|
||||||
}
|
|
||||||
frame.render_widget(Waveform::new(&buf), wave_area);
|
|
||||||
});
|
|
||||||
|
|
||||||
let duration = sample.total_frames as f32 / app.audio.config.sample_rate;
|
|
||||||
let ch_label = if sample.channels == 1 {
|
|
||||||
"mono"
|
|
||||||
} else {
|
|
||||||
"stereo"
|
|
||||||
};
|
|
||||||
let info = Paragraph::new(format!(" {duration:.1}s · {ch_label}"))
|
|
||||||
.style(Style::new().fg(theme::get().ui.text_dim));
|
|
||||||
frame.render_widget(info, info_area);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_header(
|
fn render_header(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
app: &App,
|
app: &App,
|
||||||
@@ -520,6 +450,7 @@ fn render_footer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, are
|
|||||||
Page::Help => " HELP ",
|
Page::Help => " HELP ",
|
||||||
Page::Dict => " DICT ",
|
Page::Dict => " DICT ",
|
||||||
Page::Script => " SCRIPT ",
|
Page::Script => " SCRIPT ",
|
||||||
|
Page::SampleExplorer => " SAMPLES ",
|
||||||
};
|
};
|
||||||
|
|
||||||
let content = if let Some(ref msg) = app.ui.status_message {
|
let content = if let Some(ref msg) = app.ui.status_message {
|
||||||
@@ -542,6 +473,13 @@ fn render_footer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, are
|
|||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
let bindings: Vec<(&str, &str)> = match app.page {
|
let bindings: Vec<(&str, &str)> = match app.page {
|
||||||
|
Page::SampleExplorer => vec![
|
||||||
|
("\u{2191}\u{2193}", "Navigate"),
|
||||||
|
("\u{2192}", "Expand/Play"),
|
||||||
|
("\u{2190}", "Collapse"),
|
||||||
|
("/", "Search"),
|
||||||
|
("Tab", "Close"),
|
||||||
|
],
|
||||||
Page::Main => vec![
|
Page::Main => vec![
|
||||||
("Space", "Play"),
|
("Space", "Play"),
|
||||||
("Enter", "Edit"),
|
("Enter", "Edit"),
|
||||||
@@ -552,7 +490,7 @@ fn render_footer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, are
|
|||||||
Page::Patterns => vec![
|
Page::Patterns => vec![
|
||||||
("Enter", "Select"),
|
("Enter", "Select"),
|
||||||
("Space", "Play"),
|
("Space", "Play"),
|
||||||
("c", "Commit"),
|
("c", "Launch"),
|
||||||
("r", "Rename"),
|
("r", "Rename"),
|
||||||
("?", "Keys"),
|
("?", "Keys"),
|
||||||
],
|
],
|
||||||
@@ -707,15 +645,6 @@ fn render_modal(
|
|||||||
.border_color(theme.modal.confirm)
|
.border_color(theme.modal.confirm)
|
||||||
.render_centered(frame, term)
|
.render_centered(frame, term)
|
||||||
}
|
}
|
||||||
Modal::JumpToStep(input) => {
|
|
||||||
let pattern_len = app.current_edit_pattern().length;
|
|
||||||
let title = format!("Jump to Step (1-{})", pattern_len);
|
|
||||||
TextInputModal::new(&title, input)
|
|
||||||
.hint("Enter step number")
|
|
||||||
.width(30)
|
|
||||||
.border_color(theme.modal.confirm)
|
|
||||||
.render_centered(frame, term)
|
|
||||||
}
|
|
||||||
Modal::SetTempo(input) => TextInputModal::new("Set Tempo (20-300 BPM)", input)
|
Modal::SetTempo(input) => TextInputModal::new("Set Tempo (20-300 BPM)", input)
|
||||||
.hint("Enter BPM")
|
.hint("Enter BPM")
|
||||||
.width(30)
|
.width(30)
|
||||||
@@ -728,12 +657,21 @@ fn render_modal(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|e| (e.name.clone(), e.is_dir, e.is_cagire()))
|
.map(|e| (e.name.clone(), e.is_dir, e.is_cagire()))
|
||||||
.collect();
|
.collect();
|
||||||
FileBrowserModal::new("Add Sample Path", &state.input, &entries)
|
let hints = hint_line(&[
|
||||||
|
("\u{2190}", "parent"),
|
||||||
|
("\u{2192}", "enter"),
|
||||||
|
("Enter", "add"),
|
||||||
|
("Esc", "cancel"),
|
||||||
|
]);
|
||||||
|
FileBrowserModal::new("Browse Samples", &state.input, &entries)
|
||||||
.selected(state.selected)
|
.selected(state.selected)
|
||||||
.scroll_offset(state.scroll_offset)
|
.scroll_offset(state.scroll_offset)
|
||||||
.border_color(theme.modal.rename)
|
.border_color(theme.modal.rename)
|
||||||
.width(60)
|
.audio_counts(&state.audio_counts)
|
||||||
.height(18)
|
.hints(hints)
|
||||||
|
.color_path()
|
||||||
|
.width(70)
|
||||||
|
.height(20)
|
||||||
.render_centered(frame, term)
|
.render_centered(frame, term)
|
||||||
}
|
}
|
||||||
Modal::Editor => {
|
Modal::Editor => {
|
||||||
@@ -883,6 +821,9 @@ fn render_modal(
|
|||||||
|
|
||||||
inner
|
inner
|
||||||
}
|
}
|
||||||
|
Modal::CommandPalette { input, cursor, scroll } => {
|
||||||
|
render_command_palette(frame, app, input, *cursor, *scroll, term)
|
||||||
|
}
|
||||||
Modal::KeybindingsHelp { scroll } => render_modal_keybindings(frame, app, *scroll, term),
|
Modal::KeybindingsHelp { scroll } => render_modal_keybindings(frame, app, *scroll, term),
|
||||||
Modal::EuclideanDistribution {
|
Modal::EuclideanDistribution {
|
||||||
source_step,
|
source_step,
|
||||||
@@ -1086,6 +1027,247 @@ fn render_modal_editor(
|
|||||||
inner
|
inner
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_command_palette(
|
||||||
|
frame: &mut Frame,
|
||||||
|
app: &App,
|
||||||
|
query: &str,
|
||||||
|
cursor: usize,
|
||||||
|
scroll: usize,
|
||||||
|
term: Rect,
|
||||||
|
) -> Rect {
|
||||||
|
use crate::model::palette::{palette_entries, CommandEntry};
|
||||||
|
|
||||||
|
let theme = theme::get();
|
||||||
|
let entries = palette_entries(query, app.plugin_mode, app);
|
||||||
|
|
||||||
|
// On Main page, numeric input prepends a synthetic "Jump to Step" entry
|
||||||
|
let jump_step: Option<usize> = if app.page == Page::Main
|
||||||
|
&& !query.is_empty()
|
||||||
|
&& query.chars().all(|c| c.is_ascii_digit())
|
||||||
|
{
|
||||||
|
query.parse().ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build display rows: each is either a separator header or a command entry
|
||||||
|
struct DisplayRow<'a> {
|
||||||
|
entry: Option<&'a CommandEntry>,
|
||||||
|
separator: Option<&'static str>,
|
||||||
|
is_jump: bool,
|
||||||
|
jump_label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rows: Vec<DisplayRow> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(n) = jump_step {
|
||||||
|
rows.push(DisplayRow {
|
||||||
|
entry: None,
|
||||||
|
separator: None,
|
||||||
|
is_jump: true,
|
||||||
|
jump_label: format!("Jump to Step {n}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.is_empty() {
|
||||||
|
// Grouped by category with separators
|
||||||
|
let mut last_category = "";
|
||||||
|
for e in &entries {
|
||||||
|
if e.category != last_category {
|
||||||
|
rows.push(DisplayRow {
|
||||||
|
entry: None,
|
||||||
|
separator: Some(e.category),
|
||||||
|
is_jump: false,
|
||||||
|
jump_label: String::new(),
|
||||||
|
});
|
||||||
|
last_category = e.category;
|
||||||
|
}
|
||||||
|
rows.push(DisplayRow {
|
||||||
|
entry: Some(e),
|
||||||
|
separator: None,
|
||||||
|
is_jump: false,
|
||||||
|
jump_label: String::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for e in &entries {
|
||||||
|
rows.push(DisplayRow {
|
||||||
|
entry: Some(e),
|
||||||
|
separator: None,
|
||||||
|
is_jump: false,
|
||||||
|
jump_label: String::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count selectable items (non-separator)
|
||||||
|
let selectable_count = rows.iter().filter(|r| r.separator.is_none()).count();
|
||||||
|
let cursor = cursor.min(selectable_count.saturating_sub(1));
|
||||||
|
|
||||||
|
let width: u16 = 55;
|
||||||
|
let max_height = (term.height as usize * 60 / 100).max(8);
|
||||||
|
let content_height = rows.len() + 4; // input + gap + hint + border padding
|
||||||
|
let height = content_height.min(max_height) as u16;
|
||||||
|
|
||||||
|
let inner = ModalFrame::new(": Command Palette")
|
||||||
|
.width(width)
|
||||||
|
.height(height)
|
||||||
|
.border_color(theme.modal.confirm)
|
||||||
|
.render_centered(frame, term);
|
||||||
|
|
||||||
|
let mut y = inner.y;
|
||||||
|
let content_width = inner.width;
|
||||||
|
|
||||||
|
// Input line
|
||||||
|
let input_line = Line::from(vec![
|
||||||
|
Span::styled("> ", Style::default().fg(theme.modal.confirm)),
|
||||||
|
Span::styled(query, Style::default().fg(theme.ui.text_primary)),
|
||||||
|
Span::styled("\u{2588}", Style::default().fg(theme.modal.confirm)),
|
||||||
|
]);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(input_line),
|
||||||
|
Rect::new(inner.x, y, content_width, 1),
|
||||||
|
);
|
||||||
|
y += 1;
|
||||||
|
|
||||||
|
// Visible area for entries
|
||||||
|
let visible_height = inner.height.saturating_sub(2) as usize; // minus input line and hint line
|
||||||
|
|
||||||
|
// Auto-scroll
|
||||||
|
let scroll = {
|
||||||
|
let mut s = scroll;
|
||||||
|
// Map cursor (selectable index) to row index for scrolling
|
||||||
|
let mut selectable_idx = 0;
|
||||||
|
let mut cursor_row = 0;
|
||||||
|
for (i, row) in rows.iter().enumerate() {
|
||||||
|
if row.separator.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if selectable_idx == cursor {
|
||||||
|
cursor_row = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
selectable_idx += 1;
|
||||||
|
}
|
||||||
|
if cursor_row >= s + visible_height {
|
||||||
|
s = cursor_row + 1 - visible_height;
|
||||||
|
}
|
||||||
|
if cursor_row < s {
|
||||||
|
s = cursor_row;
|
||||||
|
}
|
||||||
|
s
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render visible rows
|
||||||
|
let mut selectable_idx = rows.iter().take(scroll).filter(|r| r.separator.is_none()).count();
|
||||||
|
for row in rows.iter().skip(scroll).take(visible_height) {
|
||||||
|
if y >= inner.y + inner.height - 1 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cat) = row.separator {
|
||||||
|
// Category header
|
||||||
|
let pad = content_width.saturating_sub(cat.len() as u16 + 4) / 2;
|
||||||
|
let sep_left = "\u{2500}".repeat(pad as usize);
|
||||||
|
let sep_right =
|
||||||
|
"\u{2500}".repeat(content_width.saturating_sub(pad + cat.len() as u16 + 4) as usize);
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("{sep_left} "),
|
||||||
|
Style::default().fg(theme.ui.text_muted),
|
||||||
|
),
|
||||||
|
Span::styled(cat, Style::default().fg(theme.ui.text_dim)),
|
||||||
|
Span::styled(
|
||||||
|
format!(" {sep_right}"),
|
||||||
|
Style::default().fg(theme.ui.text_muted),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, content_width, 1));
|
||||||
|
y += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_selected = selectable_idx == cursor;
|
||||||
|
let (bg, fg) = if is_selected {
|
||||||
|
(theme.selection.cursor_bg, theme.selection.cursor_fg)
|
||||||
|
} else if selectable_idx.is_multiple_of(2) {
|
||||||
|
(theme.table.row_even, theme.ui.text_primary)
|
||||||
|
} else {
|
||||||
|
(theme.table.row_odd, theme.ui.text_primary)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (name, keybinding) = if row.is_jump {
|
||||||
|
(row.jump_label.as_str(), "")
|
||||||
|
} else if let Some(e) = row.entry {
|
||||||
|
(e.name, e.keybinding)
|
||||||
|
} else {
|
||||||
|
selectable_idx += 1;
|
||||||
|
y += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let key_len = keybinding.len() as u16;
|
||||||
|
let name_width = content_width.saturating_sub(key_len + 2);
|
||||||
|
let truncated_name: String = name.chars().take(name_width as usize).collect();
|
||||||
|
let padding = name_width.saturating_sub(truncated_name.len() as u16);
|
||||||
|
|
||||||
|
let key_fg = if is_selected {
|
||||||
|
theme.selection.cursor_fg
|
||||||
|
} else {
|
||||||
|
theme.ui.text_dim
|
||||||
|
};
|
||||||
|
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled(format!(" {truncated_name}"), Style::default().bg(bg).fg(fg)),
|
||||||
|
Span::styled(
|
||||||
|
" ".repeat(padding as usize),
|
||||||
|
Style::default().bg(bg),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!("{keybinding} "),
|
||||||
|
Style::default().bg(bg).fg(key_fg),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, content_width, 1));
|
||||||
|
|
||||||
|
selectable_idx += 1;
|
||||||
|
y += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
if selectable_count == 0 {
|
||||||
|
let msg = "No matching commands";
|
||||||
|
let empty_y = inner.y + inner.height / 2;
|
||||||
|
if empty_y < inner.y + inner.height - 1 {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(msg)
|
||||||
|
.style(Style::default().fg(theme.ui.text_muted))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
Rect::new(inner.x, empty_y, content_width, 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hint bar
|
||||||
|
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
|
||||||
|
let hints = if jump_step.is_some() && cursor == 0 {
|
||||||
|
hint_line(&[
|
||||||
|
("\u{2191}\u{2193}", "navigate"),
|
||||||
|
("Enter", "jump to step"),
|
||||||
|
("Esc", "close"),
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
hint_line(&[
|
||||||
|
("\u{2191}\u{2193}", "navigate"),
|
||||||
|
("Enter", "run"),
|
||||||
|
("Esc", "close"),
|
||||||
|
])
|
||||||
|
};
|
||||||
|
frame.render_widget(Paragraph::new(hints), hint_area);
|
||||||
|
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
|
||||||
fn render_modal_keybindings(frame: &mut Frame, app: &App, scroll: usize, term: Rect) -> Rect {
|
fn render_modal_keybindings(frame: &mut Frame, app: &App, scroll: usize, term: Rect) -> Rect {
|
||||||
let theme = theme::get();
|
let theme = theme::get();
|
||||||
let width = (term.width * 80 / 100).clamp(60, 100);
|
let width = (term.width * 80 / 100).clamp(60, 100);
|
||||||
@@ -1098,7 +1280,7 @@ fn render_modal_keybindings(frame: &mut Frame, app: &App, scroll: usize, term: R
|
|||||||
.border_color(theme.modal.editor)
|
.border_color(theme.modal.editor)
|
||||||
.render_centered(frame, term);
|
.render_centered(frame, term);
|
||||||
|
|
||||||
let bindings = super::keybindings::bindings_for(app.page, app.plugin_mode);
|
let bindings = crate::model::palette::bindings_for(app.page, app.plugin_mode);
|
||||||
let visible_rows = inner.height.saturating_sub(2) as usize;
|
let visible_rows = inner.height.saturating_sub(2) as usize;
|
||||||
|
|
||||||
let rows: Vec<Row> = bindings
|
let rows: Vec<Row> = bindings
|
||||||
|
|||||||
87
src/views/sample_explorer_view.rs
Normal file
87
src/views/sample_explorer_view.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::Style;
|
||||||
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::theme;
|
||||||
|
use crate::widgets::{SampleBrowser, Waveform};
|
||||||
|
|
||||||
|
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
render_browser(frame, app, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_browser(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let state = match &app.sample_browser {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let [tree_area, preview_area] =
|
||||||
|
Layout::vertical([Constraint::Fill(1), Constraint::Length(6)]).areas(area);
|
||||||
|
|
||||||
|
let mut vh = tree_area.height.saturating_sub(2) as usize;
|
||||||
|
if state.search_active || !state.search_query.is_empty() {
|
||||||
|
vh = vh.saturating_sub(1);
|
||||||
|
}
|
||||||
|
state.visible_height.set(vh);
|
||||||
|
|
||||||
|
let entries = state.entries();
|
||||||
|
SampleBrowser::new(&entries, state.cursor)
|
||||||
|
.scroll_offset(state.scroll_offset)
|
||||||
|
.search(&state.search_query, state.search_active)
|
||||||
|
.focused(true)
|
||||||
|
.render(frame, tree_area);
|
||||||
|
|
||||||
|
render_waveform_preview(frame, app, state, preview_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_waveform_preview(
|
||||||
|
frame: &mut Frame,
|
||||||
|
app: &App,
|
||||||
|
state: &crate::state::SampleBrowserState,
|
||||||
|
area: Rect,
|
||||||
|
) {
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::Paragraph;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
let sample = match state
|
||||||
|
.sample_key()
|
||||||
|
.and_then(|key| app.audio.sample_registry.as_ref()?.get(&key))
|
||||||
|
.filter(|s| s.frame_count >= s.total_frames)
|
||||||
|
{
|
||||||
|
Some(s) => s,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static MONO_BUF: RefCell<Vec<f32>> = const { RefCell::new(Vec::new()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let [wave_area, info_area] =
|
||||||
|
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
|
||||||
|
|
||||||
|
MONO_BUF.with(|buf| {
|
||||||
|
let mut buf = buf.borrow_mut();
|
||||||
|
let channels = sample.channels as usize;
|
||||||
|
let frame_count = sample.frame_count as usize;
|
||||||
|
buf.clear();
|
||||||
|
buf.reserve(frame_count);
|
||||||
|
for i in 0..frame_count {
|
||||||
|
buf.push(sample.frames[i * channels]);
|
||||||
|
}
|
||||||
|
frame.render_widget(Waveform::new(&buf), wave_area);
|
||||||
|
});
|
||||||
|
|
||||||
|
let duration = sample.total_frames as f32 / app.audio.config.sample_rate;
|
||||||
|
let ch_label = if sample.channels == 1 {
|
||||||
|
"mono"
|
||||||
|
} else {
|
||||||
|
"stereo"
|
||||||
|
};
|
||||||
|
let info = Paragraph::new(Line::from(Span::styled(
|
||||||
|
format!(" {duration:.1}s \u{00B7} {ch_label}"),
|
||||||
|
Style::new().fg(theme::get().ui.text_dim),
|
||||||
|
)));
|
||||||
|
frame.render_widget(info, info_area);
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ fn redefine_word_overwrites() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn word_with_param() {
|
fn word_with_param() {
|
||||||
let outputs = expect_outputs(": loud 0.9 gain ; \"kick\" s loud .", 1);
|
let outputs = expect_outputs(": loud 0.9 gain ; \"kick\" snd loud .", 1);
|
||||||
assert!(outputs[0].contains("gain/0.9"));
|
assert!(outputs[0].contains("gain/0.9"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ fn define_word_containing_quotation() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn define_word_with_sound() {
|
fn define_word_with_sound() {
|
||||||
let outputs = expect_outputs(": kick \"kick\" s . ; kick", 1);
|
let outputs = expect_outputs(": kick \"kick\" snd . ; kick", 1);
|
||||||
assert!(outputs[0].contains("sound/kick"));
|
assert!(outputs[0].contains("sound/kick"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ use cagire::forth::Value;
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_midi_channel_set() {
|
fn test_midi_channel_set() {
|
||||||
let outputs = expect_outputs("60 note 100 velocity 3 chan m.", 1);
|
let outputs = expect_outputs("60 note 0.8 velocity 3 chan m.", 1);
|
||||||
assert!(outputs[0].starts_with("/midi/note/60/vel/100/chan/2/dur/"));
|
assert!(outputs[0].starts_with("/midi/note/60/vel/101/chan/2/dur/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_midi_note_default_channel() {
|
fn test_midi_note_default_channel() {
|
||||||
let outputs = expect_outputs("72 note 80 velocity m.", 1);
|
let outputs = expect_outputs("72 note 0.6 velocity m.", 1);
|
||||||
assert!(outputs[0].starts_with("/midi/note/72/vel/80/chan/0/dur/"));
|
assert!(outputs[0].starts_with("/midi/note/72/vel/76/chan/0/dur/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -79,43 +79,43 @@ fn test_ccval_reads_from_cc_memory() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_midi_channel_clamping() {
|
fn test_midi_channel_clamping() {
|
||||||
// Channel should be clamped 1-16, then converted to 0-15 internally
|
// Channel should be clamped 1-16, then converted to 0-15 internally
|
||||||
let outputs = expect_outputs("60 note 100 velocity 0 chan m.", 1);
|
let outputs = expect_outputs("60 note 0.8 velocity 0 chan m.", 1);
|
||||||
assert!(outputs[0].contains("/chan/0")); // 0 clamped to 1, then -1 = 0
|
assert!(outputs[0].contains("/chan/0")); // 0 clamped to 1, then -1 = 0
|
||||||
|
|
||||||
let outputs = expect_outputs("60 note 100 velocity 17 chan m.", 1);
|
let outputs = expect_outputs("60 note 0.8 velocity 17 chan m.", 1);
|
||||||
assert!(outputs[0].contains("/chan/15")); // 17 clamped to 16, then -1 = 15
|
assert!(outputs[0].contains("/chan/15")); // 17 clamped to 16, then -1 = 15
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_midi_note_clamping() {
|
fn test_midi_note_clamping() {
|
||||||
let outputs = expect_outputs("-1 note 100 velocity m.", 1);
|
let outputs = expect_outputs("-1 note 0.8 velocity m.", 1);
|
||||||
assert!(outputs[0].contains("/note/0"));
|
assert!(outputs[0].contains("/note/0"));
|
||||||
|
|
||||||
let outputs = expect_outputs("200 note 100 velocity m.", 1);
|
let outputs = expect_outputs("200 note 0.8 velocity m.", 1);
|
||||||
assert!(outputs[0].contains("/note/127"));
|
assert!(outputs[0].contains("/note/127"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_midi_velocity_clamping() {
|
fn test_midi_velocity_clamping() {
|
||||||
let outputs = expect_outputs("60 note -10 velocity m.", 1);
|
let outputs = expect_outputs("60 note -0.1 velocity m.", 1);
|
||||||
assert!(outputs[0].contains("/vel/0"));
|
assert!(outputs[0].contains("/vel/0"));
|
||||||
|
|
||||||
let outputs = expect_outputs("60 note 200 velocity m.", 1);
|
let outputs = expect_outputs("60 note 2.0 velocity m.", 1);
|
||||||
assert!(outputs[0].contains("/vel/127"));
|
assert!(outputs[0].contains("/vel/127"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_midi_defaults() {
|
fn test_midi_defaults() {
|
||||||
// With only note specified, velocity defaults to 100 and channel to 0
|
// With only note specified, velocity defaults to 0.8 (101) and channel to 0
|
||||||
let outputs = expect_outputs("60 note m.", 1);
|
let outputs = expect_outputs("60 note m.", 1);
|
||||||
assert!(outputs[0].starts_with("/midi/note/60/vel/100/chan/0/dur/"));
|
assert!(outputs[0].starts_with("/midi/note/60/vel/101/chan/0/dur/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_midi_full_defaults() {
|
fn test_midi_full_defaults() {
|
||||||
// With nothing specified, defaults to note=60, velocity=100, channel=0
|
// With nothing specified, defaults to note=60, velocity=0.8 (101), channel=0
|
||||||
let outputs = expect_outputs("m.", 1);
|
let outputs = expect_outputs("m.", 1);
|
||||||
assert!(outputs[0].starts_with("/midi/note/60/vel/100/chan/0/dur/"));
|
assert!(outputs[0].starts_with("/midi/note/60/vel/101/chan/0/dur/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pitch bend tests
|
// Pitch bend tests
|
||||||
@@ -344,10 +344,10 @@ fn test_midi_polyphonic_notes() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_midi_polyphonic_notes_with_velocity() {
|
fn test_midi_polyphonic_notes_with_velocity() {
|
||||||
let outputs = expect_outputs("60 64 67 note 100 80 60 velocity m.", 3);
|
let outputs = expect_outputs("60 64 67 note 0.8 0.6 0.5 velocity m.", 3);
|
||||||
assert!(outputs[0].contains("/note/60/vel/100/"));
|
assert!(outputs[0].contains("/note/60/vel/101/"));
|
||||||
assert!(outputs[1].contains("/note/64/vel/80/"));
|
assert!(outputs[1].contains("/note/64/vel/76/"));
|
||||||
assert!(outputs[2].contains("/note/67/vel/60/"));
|
assert!(outputs[2].contains("/note/67/vel/63/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -59,20 +59,20 @@ fn nested_quotations() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn quotation_with_param() {
|
fn quotation_with_param() {
|
||||||
let outputs = expect_outputs(r#""kick" s ( 2 distort ) 1 ? ."#, 1);
|
let outputs = expect_outputs(r#""kick" snd ( 2 distort ) 1 ? ."#, 1);
|
||||||
assert!(outputs[0].contains("distort/2"));
|
assert!(outputs[0].contains("distort/2"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn quotation_skips_param() {
|
fn quotation_skips_param() {
|
||||||
let outputs = expect_outputs(r#""kick" s ( 2 distort ) 0 ? ."#, 1);
|
let outputs = expect_outputs(r#""kick" snd ( 2 distort ) 0 ? ."#, 1);
|
||||||
assert!(!outputs[0].contains("distort"));
|
assert!(!outputs[0].contains("distort"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn quotation_with_emit() {
|
fn quotation_with_emit() {
|
||||||
// When true, . should fire
|
// When true, . should fire
|
||||||
let outputs = expect_outputs(r#""kick" s ( . ) 1 ?"#, 1);
|
let outputs = expect_outputs(r#""kick" snd ( . ) 1 ?"#, 1);
|
||||||
assert!(outputs[0].contains("kick"));
|
assert!(outputs[0].contains("kick"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ fn quotation_skips_emit() {
|
|||||||
// When false, . should not fire
|
// When false, . should not fire
|
||||||
let f = forth();
|
let f = forth();
|
||||||
let outputs = f
|
let outputs = f
|
||||||
.evaluate(r#""kick" s ( . ) 0 ?"#, &default_ctx())
|
.evaluate(r#""kick" snd ( . ) 0 ?"#, &default_ctx())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// No output since . was skipped and no implicit emit
|
// No output since . was skipped and no implicit emit
|
||||||
assert_eq!(outputs.len(), 0);
|
assert_eq!(outputs.len(), 0);
|
||||||
@@ -109,7 +109,7 @@ fn every_with_quotation_integration() {
|
|||||||
let ctx = ctx_with(|c| c.iter = iter);
|
let ctx = ctx_with(|c| c.iter = iter);
|
||||||
let f = forth();
|
let f = forth();
|
||||||
let outputs = f
|
let outputs = f
|
||||||
.evaluate(r#""kick" s ( 2 distort ) 2 every ."#, &ctx)
|
.evaluate(r#""kick" snd ( 2 distort ) 2 every ."#, &ctx)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
if iter % 2 == 0 {
|
if iter % 2 == 0 {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -134,7 +134,7 @@ fn bjork_with_sound() {
|
|||||||
let ctx = ctx_with(|c| c.runs = 2); // position 2 is a hit for (3,8)
|
let ctx = ctx_with(|c| c.runs = 2); // position 2 is a hit for (3,8)
|
||||||
let f = forth();
|
let f = forth();
|
||||||
let outputs = f
|
let outputs = f
|
||||||
.evaluate(r#""kick" s ( 2 distort ) 3 8 bjork ."#, &ctx)
|
.evaluate(r#""kick" snd ( 2 distort ) 3 8 bjork ."#, &ctx)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(outputs[0].contains("distort/2"));
|
assert!(outputs[0].contains("distort/2"));
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ fn when_and_unless_complementary() {
|
|||||||
let f = forth();
|
let f = forth();
|
||||||
let outputs = f
|
let outputs = f
|
||||||
.evaluate(
|
.evaluate(
|
||||||
r#""kick" s ( 2 distort ) iter 2 mod 0 = ? ( 4 distort ) iter 2 mod 0 = !? ."#,
|
r#""kick" snd ( 2 distort ) iter 2 mod 0 = ? ( 4 distort ) iter 2 mod 0 = !? ."#,
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ fn basic_emit() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn alias_s() {
|
fn alias_s() {
|
||||||
let outputs = expect_outputs(r#""snare" s ."#, 1);
|
let outputs = expect_outputs(r#""snare" snd ."#, 1);
|
||||||
assert!(outputs[0].contains("sound/snare"));
|
assert!(outputs[0].contains("sound/snare"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn with_params() {
|
fn with_params() {
|
||||||
let outputs = expect_outputs(r#""kick" s 440 freq 0.5 gain ."#, 1);
|
let outputs = expect_outputs(r#""kick" snd 440 freq 0.5 gain ."#, 1);
|
||||||
assert!(outputs[0].contains("sound/kick"));
|
assert!(outputs[0].contains("sound/kick"));
|
||||||
assert!(outputs[0].contains("freq/440"));
|
assert!(outputs[0].contains("freq/440"));
|
||||||
assert!(outputs[0].contains("gain/0.5"));
|
assert!(outputs[0].contains("gain/0.5"));
|
||||||
@@ -22,13 +22,13 @@ fn with_params() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn auto_dur() {
|
fn auto_dur() {
|
||||||
let outputs = expect_outputs(r#""kick" s ."#, 1);
|
let outputs = expect_outputs(r#""kick" snd ."#, 1);
|
||||||
assert!(outputs[0].contains("dur/"));
|
assert!(outputs[0].contains("dur/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn auto_delaytime() {
|
fn auto_delaytime() {
|
||||||
let outputs = expect_outputs(r#""kick" s ."#, 1);
|
let outputs = expect_outputs(r#""kick" snd ."#, 1);
|
||||||
assert!(outputs[0].contains("delaytime/"));
|
assert!(outputs[0].contains("delaytime/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ fn emit_no_sound() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multiple_emits() {
|
fn multiple_emits() {
|
||||||
let outputs = expect_outputs(r#""kick" s . "snare" s ."#, 2);
|
let outputs = expect_outputs(r#""kick" snd . "snare" snd ."#, 2);
|
||||||
assert!(outputs[0].contains("sound/kick"));
|
assert!(outputs[0].contains("sound/kick"));
|
||||||
assert!(outputs[1].contains("sound/snare"));
|
assert!(outputs[1].contains("sound/snare"));
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,7 @@ fn multiple_emits() {
|
|||||||
fn envelope_params() {
|
fn envelope_params() {
|
||||||
// Values are tempo-scaled: 0.01 * step_duration(0.125) = 0.00125, etc.
|
// Values are tempo-scaled: 0.01 * step_duration(0.125) = 0.00125, etc.
|
||||||
let outputs = expect_outputs(
|
let outputs = expect_outputs(
|
||||||
r#""synth" s 0.01 attack 0.1 decay 0.7 sustain 0.3 release ."#,
|
r#""synth" snd 0.01 attack 0.1 decay 0.7 sustain 0.3 release ."#,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
assert!(outputs[0].contains("attack/0.00125"));
|
assert!(outputs[0].contains("attack/0.00125"));
|
||||||
@@ -59,14 +59,14 @@ fn envelope_params() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filter_params() {
|
fn filter_params() {
|
||||||
let outputs = expect_outputs(r#""synth" s 2000 lpf 0.5 lpq ."#, 1);
|
let outputs = expect_outputs(r#""synth" snd 2000 lpf 0.5 lpq ."#, 1);
|
||||||
assert!(outputs[0].contains("lpf/2000"));
|
assert!(outputs[0].contains("lpf/2000"));
|
||||||
assert!(outputs[0].contains("lpq/0.5"));
|
assert!(outputs[0].contains("lpq/0.5"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adsr_sets_all_envelope_params() {
|
fn adsr_sets_all_envelope_params() {
|
||||||
let outputs = expect_outputs(r#""synth" s 0.01 0.1 0.5 0.3 adsr ."#, 1);
|
let outputs = expect_outputs(r#""synth" snd 0.01 0.1 0.5 0.3 adsr ."#, 1);
|
||||||
assert!(outputs[0].contains("attack/0.00125"));
|
assert!(outputs[0].contains("attack/0.00125"));
|
||||||
assert!(outputs[0].contains("decay/0.0125"));
|
assert!(outputs[0].contains("decay/0.0125"));
|
||||||
assert!(outputs[0].contains("sustain/0.5"));
|
assert!(outputs[0].contains("sustain/0.5"));
|
||||||
@@ -75,7 +75,7 @@ fn adsr_sets_all_envelope_params() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ad_sets_attack_decay_sustain_zero() {
|
fn ad_sets_attack_decay_sustain_zero() {
|
||||||
let outputs = expect_outputs(r#""synth" s 0.01 0.1 ad ."#, 1);
|
let outputs = expect_outputs(r#""synth" snd 0.01 0.1 ad ."#, 1);
|
||||||
assert!(outputs[0].contains("attack/0.00125"));
|
assert!(outputs[0].contains("attack/0.00125"));
|
||||||
assert!(outputs[0].contains("decay/0.0125"));
|
assert!(outputs[0].contains("decay/0.0125"));
|
||||||
assert!(outputs[0].contains("sustain/0"));
|
assert!(outputs[0].contains("sustain/0"));
|
||||||
@@ -83,7 +83,7 @@ fn ad_sets_attack_decay_sustain_zero() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bank_param() {
|
fn bank_param() {
|
||||||
let outputs = expect_outputs(r#""loop" s "a" bank ."#, 1);
|
let outputs = expect_outputs(r#""loop" snd "a" bank ."#, 1);
|
||||||
assert!(outputs[0].contains("sound/loop"));
|
assert!(outputs[0].contains("sound/loop"));
|
||||||
assert!(outputs[0].contains("bank/a"));
|
assert!(outputs[0].contains("bank/a"));
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ fn param_only_multiple_params() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn polyphonic_notes() {
|
fn polyphonic_notes() {
|
||||||
let outputs = expect_outputs(r#"60 64 67 note sine s ."#, 3);
|
let outputs = expect_outputs(r#"60 64 67 note sine snd ."#, 3);
|
||||||
assert!(outputs[0].contains("note/60"));
|
assert!(outputs[0].contains("note/60"));
|
||||||
assert!(outputs[1].contains("note/64"));
|
assert!(outputs[1].contains("note/64"));
|
||||||
assert!(outputs[2].contains("note/67"));
|
assert!(outputs[2].contains("note/67"));
|
||||||
@@ -117,14 +117,14 @@ fn polyphonic_notes() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn polyphonic_sounds() {
|
fn polyphonic_sounds() {
|
||||||
let outputs = expect_outputs(r#"440 freq kick hat s ."#, 2);
|
let outputs = expect_outputs(r#"440 freq kick hat snd ."#, 2);
|
||||||
assert!(outputs[0].contains("sound/kick"));
|
assert!(outputs[0].contains("sound/kick"));
|
||||||
assert!(outputs[1].contains("sound/hat"));
|
assert!(outputs[1].contains("sound/hat"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn polyphonic_cycling() {
|
fn polyphonic_cycling() {
|
||||||
let outputs = expect_outputs(r#"60 64 67 note 0.5 1.0 gain sine s ."#, 3);
|
let outputs = expect_outputs(r#"60 64 67 note 0.5 1.0 gain sine snd ."#, 3);
|
||||||
assert!(outputs[0].contains("note/60"));
|
assert!(outputs[0].contains("note/60"));
|
||||||
assert!(outputs[0].contains("gain/0.5"));
|
assert!(outputs[0].contains("gain/0.5"));
|
||||||
assert!(outputs[1].contains("note/64"));
|
assert!(outputs[1].contains("note/64"));
|
||||||
@@ -135,7 +135,7 @@ fn polyphonic_cycling() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn polyphonic_with_at() {
|
fn polyphonic_with_at() {
|
||||||
let outputs = expect_outputs(r#"0 0.5 at 60 64 note sine s ."#, 4);
|
let outputs = expect_outputs(r#"0 0.5 at 60 64 note sine snd ."#, 4);
|
||||||
assert_eq!(outputs.len(), 4);
|
assert_eq!(outputs.len(), 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ fn explicit_dur_zero_is_infinite() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn all_before_sounds() {
|
fn all_before_sounds() {
|
||||||
let outputs = expect_outputs(
|
let outputs = expect_outputs(
|
||||||
r#"500 lpf 0.5 verb all "kick" s 60 note . "hat" s 70 note ."#,
|
r#"500 lpf 0.5 verb all "kick" snd 60 note . "hat" snd 70 note ."#,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
assert!(outputs[0].contains("sound/kick"));
|
assert!(outputs[0].contains("sound/kick"));
|
||||||
@@ -162,7 +162,7 @@ fn all_before_sounds() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn all_after_sounds() {
|
fn all_after_sounds() {
|
||||||
let outputs = expect_outputs(
|
let outputs = expect_outputs(
|
||||||
r#""kick" s 60 note . "hat" s 70 note . 500 lpf 0.5 verb all"#,
|
r#""kick" snd 60 note . "hat" snd 70 note . 500 lpf 0.5 verb all"#,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
assert!(outputs[0].contains("sound/kick"));
|
assert!(outputs[0].contains("sound/kick"));
|
||||||
@@ -176,7 +176,7 @@ fn all_after_sounds() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn noall_clears_global_params() {
|
fn noall_clears_global_params() {
|
||||||
let outputs = expect_outputs(
|
let outputs = expect_outputs(
|
||||||
r#"500 lpf all "kick" s 60 note . noall "hat" s 70 note ."#,
|
r#"500 lpf all "kick" snd 60 note . noall "hat" snd 70 note ."#,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
assert!(outputs[0].contains("lpf/500"));
|
assert!(outputs[0].contains("lpf/500"));
|
||||||
@@ -187,7 +187,7 @@ fn noall_clears_global_params() {
|
|||||||
fn all_with_tempo_scaled_params() {
|
fn all_with_tempo_scaled_params() {
|
||||||
// attack is tempo-scaled: 0.01 * step_duration(0.125) = 0.00125
|
// attack is tempo-scaled: 0.01 * step_duration(0.125) = 0.00125
|
||||||
let outputs = expect_outputs(
|
let outputs = expect_outputs(
|
||||||
r#"0.01 attack all "kick" s 60 note ."#,
|
r#"0.01 attack all "kick" snd 60 note ."#,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
assert!(outputs[0].contains("attack/0.00125"));
|
assert!(outputs[0].contains("attack/0.00125"));
|
||||||
@@ -196,7 +196,7 @@ fn all_with_tempo_scaled_params() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn all_per_sound_override() {
|
fn all_per_sound_override() {
|
||||||
let outputs = expect_outputs(
|
let outputs = expect_outputs(
|
||||||
r#"500 lpf all "kick" s 2000 lpf . "hat" s ."#,
|
r#"500 lpf all "kick" snd 2000 lpf . "hat" snd ."#,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
// kick has both global lpf=500 and per-sound lpf=2000; per-sound wins (comes last)
|
// kick has both global lpf=500 and per-sound lpf=2000; per-sound wins (comes last)
|
||||||
@@ -210,7 +210,7 @@ fn all_persists_across_evaluations() {
|
|||||||
let f = forth();
|
let f = forth();
|
||||||
let ctx = default_ctx();
|
let ctx = default_ctx();
|
||||||
f.evaluate(r#"500 lpf 0.5 verb all"#, &ctx).unwrap();
|
f.evaluate(r#"500 lpf 0.5 verb all"#, &ctx).unwrap();
|
||||||
let outputs = f.evaluate(r#""kick" s 60 note ."#, &ctx).unwrap();
|
let outputs = f.evaluate(r#""kick" snd 60 note ."#, &ctx).unwrap();
|
||||||
assert_eq!(outputs.len(), 1);
|
assert_eq!(outputs.len(), 1);
|
||||||
assert!(outputs[0].contains("lpf/500"), "global lpf missing: {}", outputs[0]);
|
assert!(outputs[0].contains("lpf/500"), "global lpf missing: {}", outputs[0]);
|
||||||
assert!(outputs[0].contains("verb/0.5"), "global verb missing: {}", outputs[0]);
|
assert!(outputs[0].contains("verb/0.5"), "global verb missing: {}", outputs[0]);
|
||||||
@@ -222,7 +222,7 @@ fn noall_clears_across_evaluations() {
|
|||||||
let ctx = default_ctx();
|
let ctx = default_ctx();
|
||||||
f.evaluate(r#"500 lpf all"#, &ctx).unwrap();
|
f.evaluate(r#"500 lpf all"#, &ctx).unwrap();
|
||||||
f.evaluate(r#"noall"#, &ctx).unwrap();
|
f.evaluate(r#"noall"#, &ctx).unwrap();
|
||||||
let outputs = f.evaluate(r#""kick" s 60 note ."#, &ctx).unwrap();
|
let outputs = f.evaluate(r#""kick" snd 60 note ."#, &ctx).unwrap();
|
||||||
assert_eq!(outputs.len(), 1);
|
assert_eq!(outputs.len(), 1);
|
||||||
assert!(!outputs[0].contains("lpf"), "lpf should be cleared: {}", outputs[0]);
|
assert!(!outputs[0].contains("lpf"), "lpf should be cleared: {}", outputs[0]);
|
||||||
}
|
}
|
||||||
@@ -251,20 +251,20 @@ fn all_replaces_previous_global() {
|
|||||||
let ctx = default_ctx();
|
let ctx = default_ctx();
|
||||||
f.evaluate(r#"500 lpf 0.5 verb all"#, &ctx).unwrap();
|
f.evaluate(r#"500 lpf 0.5 verb all"#, &ctx).unwrap();
|
||||||
f.evaluate(r#"2000 lpf all"#, &ctx).unwrap();
|
f.evaluate(r#"2000 lpf all"#, &ctx).unwrap();
|
||||||
let outputs = f.evaluate(r#""kick" s ."#, &ctx).unwrap();
|
let outputs = f.evaluate(r#""kick" snd ."#, &ctx).unwrap();
|
||||||
assert_eq!(outputs.len(), 1);
|
assert_eq!(outputs.len(), 1);
|
||||||
assert!(outputs[0].contains("lpf/2000"), "latest lpf should be 2000: {}", outputs[0]);
|
assert!(outputs[0].contains("lpf/2000"), "latest lpf should be 2000: {}", outputs[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slice_param() {
|
fn slice_param() {
|
||||||
let outputs = expect_outputs(r#""break" s 8 slice ."#, 1);
|
let outputs = expect_outputs(r#""break" snd 8 slice ."#, 1);
|
||||||
assert!(outputs[0].contains("slice/8"));
|
assert!(outputs[0].contains("slice/8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pick_param() {
|
fn pick_param() {
|
||||||
let outputs = expect_outputs(r#""break" s 8 slice 3 pick ."#, 1);
|
let outputs = expect_outputs(r#""break" snd 8 slice 3 pick ."#, 1);
|
||||||
assert!(outputs[0].contains("slice/8"));
|
assert!(outputs[0].contains("slice/8"));
|
||||||
assert!(outputs[0].contains("pick/3"));
|
assert!(outputs[0].contains("pick/3"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,14 +56,14 @@ fn stepdur_baseline() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn single_emit() {
|
fn single_emit() {
|
||||||
let outputs = expect_outputs(r#""kick" s ."#, 1);
|
let outputs = expect_outputs(r#""kick" snd ."#, 1);
|
||||||
let deltas = get_deltas(&outputs);
|
let deltas = get_deltas(&outputs);
|
||||||
assert!(approx_eq(deltas[0], 0.0), "single emit at start should have delta 0");
|
assert!(approx_eq(deltas[0], 0.0), "single emit at start should have delta 0");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multiple_emits_all_at_zero() {
|
fn multiple_emits_all_at_zero() {
|
||||||
let outputs = expect_outputs(r#""kick" s . . . ."#, 4);
|
let outputs = expect_outputs(r#""kick" snd . . . ."#, 4);
|
||||||
let deltas = get_deltas(&outputs);
|
let deltas = get_deltas(&outputs);
|
||||||
for (i, delta) in deltas.iter().enumerate() {
|
for (i, delta) in deltas.iter().enumerate() {
|
||||||
assert!(approx_eq(*delta, 0.0), "emit {}: expected delta 0, got {}", i, delta);
|
assert!(approx_eq(*delta, 0.0), "emit {}: expected delta 0, got {}", i, delta);
|
||||||
@@ -72,7 +72,7 @@ fn multiple_emits_all_at_zero() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sound_persists() {
|
fn sound_persists() {
|
||||||
let outputs = expect_outputs(r#""kick" s . . "hat" s . ."#, 4);
|
let outputs = expect_outputs(r#""kick" snd . . "hat" snd . ."#, 4);
|
||||||
let sounds = get_sounds(&outputs);
|
let sounds = get_sounds(&outputs);
|
||||||
assert_eq!(sounds[0], "kick");
|
assert_eq!(sounds[0], "kick");
|
||||||
assert_eq!(sounds[1], "kick");
|
assert_eq!(sounds[1], "kick");
|
||||||
@@ -82,14 +82,14 @@ fn sound_persists() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn alternating_sounds() {
|
fn alternating_sounds() {
|
||||||
let outputs = expect_outputs(r#""kick" s . "snare" s . "kick" s . "snare" s ."#, 4);
|
let outputs = expect_outputs(r#""kick" snd . "snare" snd . "kick" snd . "snare" snd ."#, 4);
|
||||||
let sounds = get_sounds(&outputs);
|
let sounds = get_sounds(&outputs);
|
||||||
assert_eq!(sounds, vec!["kick", "snare", "kick", "snare"]);
|
assert_eq!(sounds, vec!["kick", "snare", "kick", "snare"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dur_is_step_duration() {
|
fn dur_is_step_duration() {
|
||||||
let outputs = expect_outputs(r#""kick" s ."#, 1);
|
let outputs = expect_outputs(r#""kick" snd ."#, 1);
|
||||||
let durs = get_durs(&outputs);
|
let durs = get_durs(&outputs);
|
||||||
assert!(approx_eq(durs[0], 0.5), "dur should be 4 * step_duration (0.5), got {}", durs[0]);
|
assert!(approx_eq(durs[0], 0.5), "dur should be 4 * step_duration (0.5), got {}", durs[0]);
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ fn cycle_picks_by_runs() {
|
|||||||
for runs in 0..4 {
|
for runs in 0..4 {
|
||||||
let ctx = ctx_with(|c| c.runs = runs);
|
let ctx = ctx_with(|c| c.runs = runs);
|
||||||
let f = forth();
|
let f = forth();
|
||||||
let outputs = f.evaluate(r#""kick" s ( . ) ( ) 2 cycle"#, &ctx).unwrap();
|
let outputs = f.evaluate(r#""kick" snd ( . ) ( ) 2 cycle"#, &ctx).unwrap();
|
||||||
if runs % 2 == 0 {
|
if runs % 2 == 0 {
|
||||||
assert_eq!(outputs.len(), 1, "runs={}: emit should be picked", runs);
|
assert_eq!(outputs.len(), 1, "runs={}: emit should be picked", runs);
|
||||||
} else {
|
} else {
|
||||||
@@ -113,7 +113,7 @@ fn pcycle_picks_by_iter() {
|
|||||||
for iter in 0..4 {
|
for iter in 0..4 {
|
||||||
let ctx = ctx_with(|c| c.iter = iter);
|
let ctx = ctx_with(|c| c.iter = iter);
|
||||||
let f = forth();
|
let f = forth();
|
||||||
let outputs = f.evaluate(r#""kick" s ( . ) ( ) 2 pcycle"#, &ctx).unwrap();
|
let outputs = f.evaluate(r#""kick" snd ( . ) ( ) 2 pcycle"#, &ctx).unwrap();
|
||||||
if iter % 2 == 0 {
|
if iter % 2 == 0 {
|
||||||
assert_eq!(outputs.len(), 1, "iter={}: emit should be picked", iter);
|
assert_eq!(outputs.len(), 1, "iter={}: emit should be picked", iter);
|
||||||
} else {
|
} else {
|
||||||
@@ -128,7 +128,7 @@ fn cycle_with_sounds() {
|
|||||||
let ctx = ctx_with(|c| c.runs = runs);
|
let ctx = ctx_with(|c| c.runs = runs);
|
||||||
let f = forth();
|
let f = forth();
|
||||||
let outputs = f.evaluate(
|
let outputs = f.evaluate(
|
||||||
r#"( "kick" s . ) ( "hat" s . ) ( "snare" s . ) 3 cycle"#,
|
r#"( "kick" snd . ) ( "hat" snd . ) ( "snare" snd . ) 3 cycle"#,
|
||||||
&ctx
|
&ctx
|
||||||
).unwrap();
|
).unwrap();
|
||||||
assert_eq!(outputs.len(), 1, "runs={}: expected 1 output", runs);
|
assert_eq!(outputs.len(), 1, "runs={}: expected 1 output", runs);
|
||||||
@@ -141,7 +141,7 @@ fn cycle_with_sounds() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn at_single_delta() {
|
fn at_single_delta() {
|
||||||
let outputs = expect_outputs(r#"0.5 at "kick" s ."#, 1);
|
let outputs = expect_outputs(r#"0.5 at "kick" snd ."#, 1);
|
||||||
let deltas = get_deltas(&outputs);
|
let deltas = get_deltas(&outputs);
|
||||||
let step_dur = 0.125;
|
let step_dur = 0.125;
|
||||||
assert!(approx_eq(deltas[0], 0.5 * step_dur), "expected delta at 0.5 of step, got {}", deltas[0]);
|
assert!(approx_eq(deltas[0], 0.5 * step_dur), "expected delta at 0.5 of step, got {}", deltas[0]);
|
||||||
@@ -149,7 +149,7 @@ fn at_single_delta() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn at_list_deltas() {
|
fn at_list_deltas() {
|
||||||
let outputs = expect_outputs(r#"0 0.5 at "kick" s ."#, 2);
|
let outputs = expect_outputs(r#"0 0.5 at "kick" snd ."#, 2);
|
||||||
let deltas = get_deltas(&outputs);
|
let deltas = get_deltas(&outputs);
|
||||||
let step_dur = 0.125;
|
let step_dur = 0.125;
|
||||||
assert!(approx_eq(deltas[0], 0.0), "expected delta 0, got {}", deltas[0]);
|
assert!(approx_eq(deltas[0], 0.0), "expected delta 0, got {}", deltas[0]);
|
||||||
@@ -158,7 +158,7 @@ fn at_list_deltas() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn at_three_deltas() {
|
fn at_three_deltas() {
|
||||||
let outputs = expect_outputs(r#"0 0.33 0.67 at "kick" s ."#, 3);
|
let outputs = expect_outputs(r#"0 0.33 0.67 at "kick" snd ."#, 3);
|
||||||
let deltas = get_deltas(&outputs);
|
let deltas = get_deltas(&outputs);
|
||||||
let step_dur = 0.125;
|
let step_dur = 0.125;
|
||||||
assert!(approx_eq(deltas[0], 0.0), "expected delta 0");
|
assert!(approx_eq(deltas[0], 0.0), "expected delta 0");
|
||||||
@@ -168,7 +168,7 @@ fn at_three_deltas() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn at_persists_across_emits() {
|
fn at_persists_across_emits() {
|
||||||
let outputs = expect_outputs(r#"0 0.5 at "kick" s . "hat" s ."#, 4);
|
let outputs = expect_outputs(r#"0 0.5 at "kick" snd . "hat" snd ."#, 4);
|
||||||
let sounds = get_sounds(&outputs);
|
let sounds = get_sounds(&outputs);
|
||||||
assert_eq!(sounds, vec!["kick", "kick", "hat", "hat"]);
|
assert_eq!(sounds, vec!["kick", "kick", "hat", "hat"]);
|
||||||
}
|
}
|
||||||
@@ -176,14 +176,14 @@ fn at_persists_across_emits() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn at_reset_with_zero() {
|
fn at_reset_with_zero() {
|
||||||
let outputs = expect_outputs(r#"0 0.5 at "kick" s . 0.0 at "hat" s ."#, 3);
|
let outputs = expect_outputs(r#"0 0.5 at "kick" snd . 0.0 at "hat" snd ."#, 3);
|
||||||
let sounds = get_sounds(&outputs);
|
let sounds = get_sounds(&outputs);
|
||||||
assert_eq!(sounds, vec!["kick", "kick", "hat"]);
|
assert_eq!(sounds, vec!["kick", "kick", "hat"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clear_resets_at_deltas() {
|
fn clear_resets_at_deltas() {
|
||||||
let outputs = expect_outputs(r#"0 0.5 at "kick" s . clear "hat" s ."#, 3);
|
let outputs = expect_outputs(r#"0 0.5 at "kick" snd . clear "hat" snd ."#, 3);
|
||||||
let sounds = get_sounds(&outputs);
|
let sounds = get_sounds(&outputs);
|
||||||
assert_eq!(sounds, vec!["kick", "kick", "hat"]);
|
assert_eq!(sounds, vec!["kick", "kick", "hat"]);
|
||||||
let deltas = get_deltas(&outputs);
|
let deltas = get_deltas(&outputs);
|
||||||
@@ -196,7 +196,7 @@ fn at_records_selected_spans() {
|
|||||||
|
|
||||||
let f = forth();
|
let f = forth();
|
||||||
let mut trace = ExecutionTrace::default();
|
let mut trace = ExecutionTrace::default();
|
||||||
let script = r#"0 0.5 0.75 at "kick" s ."#;
|
let script = r#"0 0.5 0.75 at "kick" snd ."#;
|
||||||
f.evaluate_with_trace(script, &default_ctx(), &mut trace).unwrap();
|
f.evaluate_with_trace(script, &default_ctx(), &mut trace).unwrap();
|
||||||
|
|
||||||
// Should have 6 selected spans: 3 for at deltas + 3 for sound (one per emit)
|
// Should have 6 selected spans: 3 for at deltas + 3 for sound (one per emit)
|
||||||
@@ -226,7 +226,7 @@ fn get_gains(outputs: &[String]) -> Vec<f64> {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn arp_auto_subdivide() {
|
fn arp_auto_subdivide() {
|
||||||
let outputs = expect_outputs(r#"sine s c4 e4 g4 b4 arp note ."#, 4);
|
let outputs = expect_outputs(r#"sine snd c4 e4 g4 b4 arp note ."#, 4);
|
||||||
let notes = get_notes(&outputs);
|
let notes = get_notes(&outputs);
|
||||||
assert!(approx_eq(notes[0], 60.0));
|
assert!(approx_eq(notes[0], 60.0));
|
||||||
assert!(approx_eq(notes[1], 64.0));
|
assert!(approx_eq(notes[1], 64.0));
|
||||||
@@ -242,7 +242,7 @@ fn arp_auto_subdivide() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn arp_with_explicit_at() {
|
fn arp_with_explicit_at() {
|
||||||
let outputs = expect_outputs(r#"0 0.25 0.5 0.75 at sine s c4 e4 g4 b4 arp note ."#, 4);
|
let outputs = expect_outputs(r#"0 0.25 0.5 0.75 at sine snd c4 e4 g4 b4 arp note ."#, 4);
|
||||||
let notes = get_notes(&outputs);
|
let notes = get_notes(&outputs);
|
||||||
assert!(approx_eq(notes[0], 60.0));
|
assert!(approx_eq(notes[0], 60.0));
|
||||||
assert!(approx_eq(notes[1], 64.0));
|
assert!(approx_eq(notes[1], 64.0));
|
||||||
@@ -258,14 +258,14 @@ fn arp_with_explicit_at() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn arp_single_note() {
|
fn arp_single_note() {
|
||||||
let outputs = expect_outputs(r#"sine s c4 arp note ."#, 1);
|
let outputs = expect_outputs(r#"sine snd c4 arp note ."#, 1);
|
||||||
let notes = get_notes(&outputs);
|
let notes = get_notes(&outputs);
|
||||||
assert!(approx_eq(notes[0], 60.0));
|
assert!(approx_eq(notes[0], 60.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn arp_fewer_deltas_than_notes() {
|
fn arp_fewer_deltas_than_notes() {
|
||||||
let outputs = expect_outputs(r#"0 0.5 at sine s c4 e4 g4 b4 arp note ."#, 4);
|
let outputs = expect_outputs(r#"0 0.5 at sine snd c4 e4 g4 b4 arp note ."#, 4);
|
||||||
let notes = get_notes(&outputs);
|
let notes = get_notes(&outputs);
|
||||||
assert!(approx_eq(notes[0], 60.0));
|
assert!(approx_eq(notes[0], 60.0));
|
||||||
assert!(approx_eq(notes[1], 64.0));
|
assert!(approx_eq(notes[1], 64.0));
|
||||||
@@ -281,7 +281,7 @@ fn arp_fewer_deltas_than_notes() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn arp_fewer_notes_than_deltas() {
|
fn arp_fewer_notes_than_deltas() {
|
||||||
let outputs = expect_outputs(r#"0 0.25 0.5 0.75 at sine s c4 e4 arp note ."#, 4);
|
let outputs = expect_outputs(r#"0 0.25 0.5 0.75 at sine snd c4 e4 arp note ."#, 4);
|
||||||
let notes = get_notes(&outputs);
|
let notes = get_notes(&outputs);
|
||||||
assert!(approx_eq(notes[0], 60.0));
|
assert!(approx_eq(notes[0], 60.0));
|
||||||
assert!(approx_eq(notes[1], 64.0));
|
assert!(approx_eq(notes[1], 64.0));
|
||||||
@@ -291,7 +291,7 @@ fn arp_fewer_notes_than_deltas() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn arp_multiple_params() {
|
fn arp_multiple_params() {
|
||||||
let outputs = expect_outputs(r#"sine s c4 e4 g4 arp note 0.5 0.7 0.9 arp gain ."#, 3);
|
let outputs = expect_outputs(r#"sine snd c4 e4 g4 arp note 0.5 0.7 0.9 arp gain ."#, 3);
|
||||||
let notes = get_notes(&outputs);
|
let notes = get_notes(&outputs);
|
||||||
assert!(approx_eq(notes[0], 60.0));
|
assert!(approx_eq(notes[0], 60.0));
|
||||||
assert!(approx_eq(notes[1], 64.0));
|
assert!(approx_eq(notes[1], 64.0));
|
||||||
@@ -305,7 +305,7 @@ fn arp_multiple_params() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn arp_no_arp_unchanged() {
|
fn arp_no_arp_unchanged() {
|
||||||
// Standard CycleList without arp → cross-product (backward compat)
|
// Standard CycleList without arp → cross-product (backward compat)
|
||||||
let outputs = expect_outputs(r#"0 0.5 at sine s c4 e4 note ."#, 4);
|
let outputs = expect_outputs(r#"0 0.5 at sine snd c4 e4 note ."#, 4);
|
||||||
let notes = get_notes(&outputs);
|
let notes = get_notes(&outputs);
|
||||||
// Cross-product: each note at each delta
|
// Cross-product: each note at each delta
|
||||||
assert!(approx_eq(notes[0], 60.0));
|
assert!(approx_eq(notes[0], 60.0));
|
||||||
@@ -318,7 +318,7 @@ fn arp_no_arp_unchanged() {
|
|||||||
fn arp_mixed_cycle_and_arp() {
|
fn arp_mixed_cycle_and_arp() {
|
||||||
// CycleList sound (2) + ArpList note (3) → 3 arp × 2 poly = 6 voices
|
// CycleList sound (2) + ArpList note (3) → 3 arp × 2 poly = 6 voices
|
||||||
// Each arp step plays both sine and saw simultaneously (poly stacking)
|
// Each arp step plays both sine and saw simultaneously (poly stacking)
|
||||||
let outputs = expect_outputs(r#"sine saw s c4 e4 g4 arp note ."#, 6);
|
let outputs = expect_outputs(r#"sine saw snd c4 e4 g4 arp note ."#, 6);
|
||||||
let sounds = get_sounds(&outputs);
|
let sounds = get_sounds(&outputs);
|
||||||
// Arp step 0: poly 0=sine, poly 1=saw
|
// Arp step 0: poly 0=sine, poly 1=saw
|
||||||
assert_eq!(sounds[0], "sine");
|
assert_eq!(sounds[0], "sine");
|
||||||
@@ -346,7 +346,7 @@ fn every_offset_fires_at_offset() {
|
|||||||
for iter in 0..8 {
|
for iter in 0..8 {
|
||||||
let ctx = ctx_with(|c| c.iter = iter);
|
let ctx = ctx_with(|c| c.iter = iter);
|
||||||
let f = forth();
|
let f = forth();
|
||||||
let outputs = f.evaluate(r#""kick" s ( . ) 4 2 every+"#, &ctx).unwrap();
|
let outputs = f.evaluate(r#""kick" snd ( . ) 4 2 every+"#, &ctx).unwrap();
|
||||||
if iter % 4 == 2 {
|
if iter % 4 == 2 {
|
||||||
assert_eq!(outputs.len(), 1, "iter={}: should fire", iter);
|
assert_eq!(outputs.len(), 1, "iter={}: should fire", iter);
|
||||||
} else {
|
} else {
|
||||||
@@ -361,7 +361,7 @@ fn every_offset_wraps_large_offset() {
|
|||||||
for iter in 0..8 {
|
for iter in 0..8 {
|
||||||
let ctx = ctx_with(|c| c.iter = iter);
|
let ctx = ctx_with(|c| c.iter = iter);
|
||||||
let f = forth();
|
let f = forth();
|
||||||
let outputs = f.evaluate(r#""kick" s ( . ) 4 6 every+"#, &ctx).unwrap();
|
let outputs = f.evaluate(r#""kick" snd ( . ) 4 6 every+"#, &ctx).unwrap();
|
||||||
if iter % 4 == 2 {
|
if iter % 4 == 2 {
|
||||||
assert_eq!(outputs.len(), 1, "iter={}: should fire (wrapped offset)", iter);
|
assert_eq!(outputs.len(), 1, "iter={}: should fire (wrapped offset)", iter);
|
||||||
} else {
|
} else {
|
||||||
@@ -375,7 +375,7 @@ fn except_offset_inverse() {
|
|||||||
for iter in 0..8 {
|
for iter in 0..8 {
|
||||||
let ctx = ctx_with(|c| c.iter = iter);
|
let ctx = ctx_with(|c| c.iter = iter);
|
||||||
let f = forth();
|
let f = forth();
|
||||||
let outputs = f.evaluate(r#""kick" s ( . ) 4 2 except+"#, &ctx).unwrap();
|
let outputs = f.evaluate(r#""kick" snd ( . ) 4 2 except+"#, &ctx).unwrap();
|
||||||
if iter % 4 != 2 {
|
if iter % 4 != 2 {
|
||||||
assert_eq!(outputs.len(), 1, "iter={}: should fire", iter);
|
assert_eq!(outputs.len(), 1, "iter={}: should fire", iter);
|
||||||
} else {
|
} else {
|
||||||
@@ -389,8 +389,8 @@ fn every_offset_zero_is_same_as_every() {
|
|||||||
for iter in 0..8 {
|
for iter in 0..8 {
|
||||||
let ctx = ctx_with(|c| c.iter = iter);
|
let ctx = ctx_with(|c| c.iter = iter);
|
||||||
let f = forth();
|
let f = forth();
|
||||||
let a = f.evaluate(r#""kick" s ( . ) 3 every"#, &ctx).unwrap();
|
let a = f.evaluate(r#""kick" snd ( . ) 3 every"#, &ctx).unwrap();
|
||||||
let b = f.evaluate(r#""kick" s ( . ) 3 0 every+"#, &ctx).unwrap();
|
let b = f.evaluate(r#""kick" snd ( . ) 3 0 every+"#, &ctx).unwrap();
|
||||||
assert_eq!(a.len(), b.len(), "iter={}: every and every+ 0 should match", iter);
|
assert_eq!(a.len(), b.len(), "iter={}: every and every+ 0 should match", iter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ const DL = 'https://dlcagire.raphaelforment.fr';
|
|||||||
<tr>
|
<tr>
|
||||||
<td>Windows (x86_64)</td>
|
<td>Windows (x86_64)</td>
|
||||||
<td><a href={`${DL}/cagire-windows-x86_64.zip`}>zip</a></td>
|
<td><a href={`${DL}/cagire-windows-x86_64.zip`}>zip</a></td>
|
||||||
<td><a href={`${DL}/cagire-windows-x86_64-desktop.zip`}>zip</a> · <a href={`${DL}/cagire-msi.zip`}>.msi</a></td>
|
<td><a href={`${DL}/cagire-desktop-windows-x86_64.zip`}>zip</a> · <s>.msi</s></td>
|
||||||
<td><s>CLAP</s> · <s>VST3</s></td>
|
<td><a href={`${DL}/plugins-windows-x86_64-clap.zip`}>CLAP</a> · <a href={`${DL}/plugins-windows-x86_64-vst3.zip`}>VST3</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p class="note">Source code and issue tracker on <a href="https://github.com/Bubobubobubobubo/cagire">GitHub</a>. You can also compile the software yourself from source!</p>
|
<p class="note">Source code and issue tracker on <a href="https://github.com/Bubobubobubobubo/cagire">GitHub</a>. You can also compile the software yourself from source!</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user