Compare commits
12 Commits
v0.0.2
...
772d21a8ed
| Author | SHA1 | Date | |
|---|---|---|---|
| 772d21a8ed | |||
| 4396147a8b | |||
| c396c39b6b | |||
| f6b43cb021 | |||
| 60d1d7ca74 | |||
| 9864cc6d61 | |||
| 985ab687d7 | |||
| 9b925d881e | |||
| 71146c7cea | |||
| 6b95f31afd | |||
| adee8d0d57 | |||
| f9c284effd |
34
CHANGELOG.md
34
CHANGELOG.md
@@ -2,7 +2,39 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [0.0.4] - 2026-02-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Double-stack words: `2dup`, `2drop`, `2swap`, `2over`.
|
||||||
|
- `forget` word to remove user-defined words from the dictionary.
|
||||||
|
- Active patterns panel showing playing patterns with bank, pattern, iteration count, and step position.
|
||||||
|
- Configurable visualization layout (Top/Bottom/Left/Right) for scope and spectrum placement.
|
||||||
|
- Euclidean distribution modal to spread a step's script across the pattern using Euclidean rhythms.
|
||||||
|
- Fairyfloss theme (pastel candy colors by sailorhg).
|
||||||
|
- Hot Dog Stand theme (classic Windows 3.1 red/yellow).
|
||||||
|
- Hue rotation option in Options menu to shift all theme colors (0-360°).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Title view now adapts to smaller terminal sizes gracefully.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Scope/spectrum ratio asymmetry in Left/Right layout modes.
|
||||||
|
|
||||||
|
## [0.0.3] - 2026-02-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Polyphonic parameters: param words (`note`, `freq`, `gain`, etc.) and sound words now consume the entire stack, enabling polyphony (e.g., `60 64 67 note sine s .` emits 3 voices).
|
||||||
|
- New random distribution words: `exprand` (exponential) and `logrand` (logarithmic).
|
||||||
|
- Music theory chord words: `maj`, `m`, `dim`, `aug`, `sus2`, `sus4`, `maj7`, `min7`, `dom7`, `dim7`, `m7b5`, `minmaj7`, `aug7`, `maj6`, `min6`, `dom9`, `maj9`, `min9`, `dom11`, `min11`, `dom13`, `add9`, `add11`, `madd9`, `dom7b9`, `dom7s9`, `dom7b5`, `dom7s5`.
|
||||||
|
- Playing patterns are now saved with the project and restored on load.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `at` now consumes the entire stack for time offsets; polyphony multiplies with deltas (2 notes × 2 times = 4 voices).
|
||||||
|
- Iterator (`iter`) now resets when a pattern restarts.
|
||||||
|
- Project loading now properly resets state: stops all patterns, clears user variables/dictionary, and clears queued changes.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- `tcycle` word (replaced by polyphonic parameter behavior).
|
||||||
|
|
||||||
## [0.0.2] - 2026-02-01
|
## [0.0.2] - 2026-02-01
|
||||||
- CI testing and codebase cleanup
|
- CI testing and codebase cleanup
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"]
|
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.0.2"
|
version = "0.0.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
|
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<img src="cagire_pixel.png" alt="Cagire" width="256">
|
<img src="cagire_pixel.png" alt="Cagire" width="256">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Cagire is a terminal-based step sequencer for live coding music. Each step in a pattern contains a **Forth** script that produces sound and create events. It is made by BuboBubo (Raphaël Maurice Forment): [https://raphaelforment.fr](https://raphaelforment.fr). Cagire is open-source (AGPL-3.0 licensed) and available on GitHub : [https://github.com/BuboBubo/cagire](https://github.com/BuboBubo/cagire).
|
Cagire is a terminal-based step sequencer for live coding music. Each step in a pattern contains a **Forth** script that produces sound and create events.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ pub enum Op {
|
|||||||
Rot,
|
Rot,
|
||||||
Nip,
|
Nip,
|
||||||
Tuck,
|
Tuck,
|
||||||
|
Dup2,
|
||||||
|
Drop2,
|
||||||
|
Swap2,
|
||||||
|
Over2,
|
||||||
|
Forget,
|
||||||
Add,
|
Add,
|
||||||
Sub,
|
Sub,
|
||||||
Mul,
|
Mul,
|
||||||
@@ -53,10 +58,11 @@ pub enum Op {
|
|||||||
Set,
|
Set,
|
||||||
GetContext(String),
|
GetContext(String),
|
||||||
Rand,
|
Rand,
|
||||||
|
ExpRand,
|
||||||
|
LogRand,
|
||||||
Seed,
|
Seed,
|
||||||
Cycle,
|
Cycle,
|
||||||
PCycle,
|
PCycle,
|
||||||
TCycle,
|
|
||||||
Choose,
|
Choose,
|
||||||
ChanceExec,
|
ChanceExec,
|
||||||
ProbExec,
|
ProbExec,
|
||||||
@@ -86,6 +92,7 @@ pub enum Op {
|
|||||||
Generate,
|
Generate,
|
||||||
GeomRange,
|
GeomRange,
|
||||||
Times,
|
Times,
|
||||||
|
Chord(&'static [i64]),
|
||||||
// MIDI
|
// MIDI
|
||||||
MidiEmit,
|
MidiEmit,
|
||||||
GetMidiCC,
|
GetMidiCC,
|
||||||
|
|||||||
129
crates/forth/src/theory/chords.rs
Normal file
129
crates/forth/src/theory/chords.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
pub struct Chord {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub intervals: &'static [i64],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub static CHORDS: &[Chord] = &[
|
||||||
|
// Triads
|
||||||
|
Chord {
|
||||||
|
name: "maj",
|
||||||
|
intervals: &[0, 4, 7],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "m",
|
||||||
|
intervals: &[0, 3, 7],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "dim",
|
||||||
|
intervals: &[0, 3, 6],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "aug",
|
||||||
|
intervals: &[0, 4, 8],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "sus2",
|
||||||
|
intervals: &[0, 2, 7],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "sus4",
|
||||||
|
intervals: &[0, 5, 7],
|
||||||
|
},
|
||||||
|
// Seventh chords
|
||||||
|
Chord {
|
||||||
|
name: "maj7",
|
||||||
|
intervals: &[0, 4, 7, 11],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "min7",
|
||||||
|
intervals: &[0, 3, 7, 10],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "dom7",
|
||||||
|
intervals: &[0, 4, 7, 10],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "dim7",
|
||||||
|
intervals: &[0, 3, 6, 9],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "m7b5",
|
||||||
|
intervals: &[0, 3, 6, 10],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "minmaj7",
|
||||||
|
intervals: &[0, 3, 7, 11],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "aug7",
|
||||||
|
intervals: &[0, 4, 8, 10],
|
||||||
|
},
|
||||||
|
// Sixth chords
|
||||||
|
Chord {
|
||||||
|
name: "maj6",
|
||||||
|
intervals: &[0, 4, 7, 9],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "min6",
|
||||||
|
intervals: &[0, 3, 7, 9],
|
||||||
|
},
|
||||||
|
// Extended chords
|
||||||
|
Chord {
|
||||||
|
name: "dom9",
|
||||||
|
intervals: &[0, 4, 7, 10, 14],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "maj9",
|
||||||
|
intervals: &[0, 4, 7, 11, 14],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "min9",
|
||||||
|
intervals: &[0, 3, 7, 10, 14],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "dom11",
|
||||||
|
intervals: &[0, 4, 7, 10, 14, 17],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "min11",
|
||||||
|
intervals: &[0, 3, 7, 10, 14, 17],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "dom13",
|
||||||
|
intervals: &[0, 4, 7, 10, 14, 21],
|
||||||
|
},
|
||||||
|
// Add chords
|
||||||
|
Chord {
|
||||||
|
name: "add9",
|
||||||
|
intervals: &[0, 4, 7, 14],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "add11",
|
||||||
|
intervals: &[0, 4, 7, 17],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "madd9",
|
||||||
|
intervals: &[0, 3, 7, 14],
|
||||||
|
},
|
||||||
|
// Altered dominants
|
||||||
|
Chord {
|
||||||
|
name: "dom7b9",
|
||||||
|
intervals: &[0, 4, 7, 10, 13],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "dom7s9",
|
||||||
|
intervals: &[0, 4, 7, 10, 15],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "dom7b5",
|
||||||
|
intervals: &[0, 4, 6, 10],
|
||||||
|
},
|
||||||
|
Chord {
|
||||||
|
name: "dom7s5",
|
||||||
|
intervals: &[0, 4, 8, 10],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn lookup(name: &str) -> Option<&'static [i64]> {
|
||||||
|
CHORDS.iter().find(|c| c.name == name).map(|c| c.intervals)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod chords;
|
||||||
mod scales;
|
mod scales;
|
||||||
|
|
||||||
pub use scales::lookup;
|
pub use scales::lookup;
|
||||||
|
|||||||
@@ -154,6 +154,14 @@ impl CmdRegister {
|
|||||||
&self.deltas
|
&self.deltas
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn sound(&self) -> Option<&Value> {
|
||||||
|
self.sound.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn params(&self) -> &[(String, Value)] {
|
||||||
|
&self.params
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn snapshot(&self) -> Option<CmdSnapshot<'_>> {
|
pub(super) fn snapshot(&self) -> Option<CmdSnapshot<'_>> {
|
||||||
if self.sound.is_some() || !self.params.is_empty() {
|
if self.sound.is_some() || !self.params.is_empty() {
|
||||||
Some((self.sound.as_ref(), self.params.as_slice()))
|
Some((self.sound.as_ref(), self.params.as_slice()))
|
||||||
|
|||||||
@@ -152,6 +152,23 @@ impl Forth {
|
|||||||
select_and_run(selected, stack, outputs, cmd)
|
select_and_run(selected, stack, outputs, cmd)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let compute_poly_count = |cmd: &CmdRegister| -> usize {
|
||||||
|
let sound_len = match cmd.sound() {
|
||||||
|
Some(Value::CycleList(items)) => items.len(),
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
let param_max = cmd
|
||||||
|
.params()
|
||||||
|
.iter()
|
||||||
|
.map(|(_, v)| match v {
|
||||||
|
Value::CycleList(items) => items.len(),
|
||||||
|
_ => 1,
|
||||||
|
})
|
||||||
|
.max()
|
||||||
|
.unwrap_or(1);
|
||||||
|
sound_len.max(param_max)
|
||||||
|
};
|
||||||
|
|
||||||
let emit_with_cycling = |cmd: &CmdRegister,
|
let emit_with_cycling = |cmd: &CmdRegister,
|
||||||
emit_idx: usize,
|
emit_idx: usize,
|
||||||
delta_secs: f64,
|
delta_secs: f64,
|
||||||
@@ -245,6 +262,42 @@ impl Forth {
|
|||||||
let v = stack[len - 1].clone();
|
let v = stack[len - 1].clone();
|
||||||
stack.insert(len - 2, v);
|
stack.insert(len - 2, v);
|
||||||
}
|
}
|
||||||
|
Op::Dup2 => {
|
||||||
|
let len = stack.len();
|
||||||
|
if len < 2 {
|
||||||
|
return Err("stack underflow".into());
|
||||||
|
}
|
||||||
|
let a = stack[len - 2].clone();
|
||||||
|
let b = stack[len - 1].clone();
|
||||||
|
stack.push(a);
|
||||||
|
stack.push(b);
|
||||||
|
}
|
||||||
|
Op::Drop2 => {
|
||||||
|
let len = stack.len();
|
||||||
|
if len < 2 {
|
||||||
|
return Err("stack underflow".into());
|
||||||
|
}
|
||||||
|
stack.pop();
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
Op::Swap2 => {
|
||||||
|
let len = stack.len();
|
||||||
|
if len < 4 {
|
||||||
|
return Err("stack underflow".into());
|
||||||
|
}
|
||||||
|
stack.swap(len - 4, len - 2);
|
||||||
|
stack.swap(len - 3, len - 1);
|
||||||
|
}
|
||||||
|
Op::Over2 => {
|
||||||
|
let len = stack.len();
|
||||||
|
if len < 4 {
|
||||||
|
return Err("stack underflow".into());
|
||||||
|
}
|
||||||
|
let a = stack[len - 4].clone();
|
||||||
|
let b = stack[len - 3].clone();
|
||||||
|
stack.push(a);
|
||||||
|
stack.push(b);
|
||||||
|
}
|
||||||
|
|
||||||
Op::Add => binary_op(stack, |a, b| a + b)?,
|
Op::Add => binary_op(stack, |a, b| a + b)?,
|
||||||
Op::Sub => binary_op(stack, |a, b| a - b)?,
|
Op::Sub => binary_op(stack, |a, b| a - b)?,
|
||||||
@@ -363,38 +416,56 @@ impl Forth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Op::NewCmd => {
|
Op::NewCmd => {
|
||||||
let val = stack.pop().ok_or("stack underflow")?;
|
if stack.is_empty() {
|
||||||
|
return Err("stack underflow".into());
|
||||||
|
}
|
||||||
|
let values = std::mem::take(stack);
|
||||||
|
let val = if values.len() == 1 {
|
||||||
|
values.into_iter().next().unwrap()
|
||||||
|
} else {
|
||||||
|
Value::CycleList(values)
|
||||||
|
};
|
||||||
cmd.set_sound(val);
|
cmd.set_sound(val);
|
||||||
}
|
}
|
||||||
Op::SetParam(param) => {
|
Op::SetParam(param) => {
|
||||||
let val = stack.pop().ok_or("stack underflow")?;
|
if stack.is_empty() {
|
||||||
|
return Err("stack underflow".into());
|
||||||
|
}
|
||||||
|
let values = std::mem::take(stack);
|
||||||
|
let val = if values.len() == 1 {
|
||||||
|
values.into_iter().next().unwrap()
|
||||||
|
} else {
|
||||||
|
Value::CycleList(values)
|
||||||
|
};
|
||||||
cmd.set_param(param.clone(), val);
|
cmd.set_param(param.clone(), val);
|
||||||
}
|
}
|
||||||
|
|
||||||
Op::Emit => {
|
Op::Emit => {
|
||||||
|
let poly_count = compute_poly_count(cmd);
|
||||||
let deltas = if cmd.deltas().is_empty() {
|
let deltas = if cmd.deltas().is_empty() {
|
||||||
vec![Value::Float(0.0, None)]
|
vec![Value::Float(0.0, None)]
|
||||||
} else {
|
} else {
|
||||||
cmd.deltas().to_vec()
|
cmd.deltas().to_vec()
|
||||||
};
|
};
|
||||||
|
|
||||||
for (emit_idx, delta_val) in deltas.iter().enumerate() {
|
for poly_idx in 0..poly_count {
|
||||||
let delta_frac = delta_val.as_float()?;
|
for delta_val in deltas.iter() {
|
||||||
let delta_secs = ctx.nudge_secs + delta_frac * ctx.step_duration();
|
let delta_frac = delta_val.as_float()?;
|
||||||
// Record delta span for highlighting
|
let delta_secs = ctx.nudge_secs + delta_frac * ctx.step_duration();
|
||||||
if let Some(span) = delta_val.span() {
|
if let Some(span) = delta_val.span() {
|
||||||
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
|
|
||||||
trace.selected_spans.push(span);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(sound_val) =
|
|
||||||
emit_with_cycling(cmd, emit_idx, delta_secs, outputs)?
|
|
||||||
{
|
|
||||||
if let Some(span) = sound_val.span() {
|
|
||||||
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
|
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
|
||||||
trace.selected_spans.push(span);
|
trace.selected_spans.push(span);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(sound_val) =
|
||||||
|
emit_with_cycling(cmd, poly_idx, delta_secs, outputs)?
|
||||||
|
{
|
||||||
|
if let Some(span) = sound_val.span() {
|
||||||
|
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
|
||||||
|
trace.selected_spans.push(span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -455,11 +526,37 @@ impl Forth {
|
|||||||
let a_f = a.as_float()?;
|
let a_f = a.as_float()?;
|
||||||
let b_f = b.as_float()?;
|
let b_f = b.as_float()?;
|
||||||
let (lo, hi) = if a_f <= b_f { (a_f, b_f) } else { (b_f, a_f) };
|
let (lo, hi) = if a_f <= b_f { (a_f, b_f) } else { (b_f, a_f) };
|
||||||
let val = self.rng.lock().unwrap().gen_range(lo..hi);
|
let val = if (hi - lo).abs() < f64::EPSILON {
|
||||||
|
lo
|
||||||
|
} else {
|
||||||
|
self.rng.lock().unwrap().gen_range(lo..hi)
|
||||||
|
};
|
||||||
stack.push(Value::Float(val, None));
|
stack.push(Value::Float(val, None));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Op::ExpRand => {
|
||||||
|
let hi = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||||
|
let lo = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||||
|
if lo <= 0.0 || hi <= 0.0 {
|
||||||
|
return Err("exprand requires positive values".into());
|
||||||
|
}
|
||||||
|
let (lo, hi) = if lo <= hi { (lo, hi) } else { (hi, lo) };
|
||||||
|
let u: f64 = self.rng.lock().unwrap().gen();
|
||||||
|
let val = lo * (hi / lo).powf(u);
|
||||||
|
stack.push(Value::Float(val, None));
|
||||||
|
}
|
||||||
|
Op::LogRand => {
|
||||||
|
let hi = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||||
|
let lo = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||||
|
if lo <= 0.0 || hi <= 0.0 {
|
||||||
|
return Err("logrand requires positive values".into());
|
||||||
|
}
|
||||||
|
let (lo, hi) = if lo <= hi { (lo, hi) } else { (hi, lo) };
|
||||||
|
let u: f64 = self.rng.lock().unwrap().gen();
|
||||||
|
let val = hi * (lo / hi).powf(u);
|
||||||
|
stack.push(Value::Float(val, None));
|
||||||
|
}
|
||||||
Op::Seed => {
|
Op::Seed => {
|
||||||
let s = stack.pop().ok_or("stack underflow")?.as_int()?;
|
let s = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||||
*self.rng.lock().unwrap() = StdRng::seed_from_u64(s as u64);
|
*self.rng.lock().unwrap() = StdRng::seed_from_u64(s as u64);
|
||||||
@@ -477,19 +574,6 @@ impl Forth {
|
|||||||
drain_select_run(count, idx, stack, outputs, cmd)?;
|
drain_select_run(count, idx, stack, outputs, cmd)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Op::TCycle => {
|
|
||||||
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
|
|
||||||
if count == 0 {
|
|
||||||
return Err("tcycle count must be > 0".into());
|
|
||||||
}
|
|
||||||
if stack.len() < count {
|
|
||||||
return Err("stack underflow".into());
|
|
||||||
}
|
|
||||||
let start = stack.len() - count;
|
|
||||||
let values: Vec<Value> = stack.drain(start..).collect();
|
|
||||||
stack.push(Value::CycleList(values));
|
|
||||||
}
|
|
||||||
|
|
||||||
Op::Choose => {
|
Op::Choose => {
|
||||||
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
|
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
@@ -607,6 +691,13 @@ impl Forth {
|
|||||||
stack.push(result);
|
stack.push(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Op::Chord(intervals) => {
|
||||||
|
let root = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||||
|
for &interval in *intervals {
|
||||||
|
stack.push(Value::Int(root + interval, None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Op::Oct => {
|
Op::Oct => {
|
||||||
let shift = stack.pop().ok_or("stack underflow")?;
|
let shift = stack.pop().ok_or("stack underflow")?;
|
||||||
let note = stack.pop().ok_or("stack underflow")?;
|
let note = stack.pop().ok_or("stack underflow")?;
|
||||||
@@ -659,27 +750,10 @@ impl Forth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Op::At => {
|
Op::At => {
|
||||||
let top = stack.pop().ok_or("stack underflow")?;
|
if stack.is_empty() {
|
||||||
let deltas = match &top {
|
return Err("stack underflow".into());
|
||||||
Value::Float(..) => vec![top],
|
}
|
||||||
Value::Int(n, _) => {
|
let deltas = std::mem::take(stack);
|
||||||
let count = *n as usize;
|
|
||||||
if stack.len() < count {
|
|
||||||
return Err(format!(
|
|
||||||
"at: stack underflow, expected {} values but got {}",
|
|
||||||
count,
|
|
||||||
stack.len()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut vals = Vec::with_capacity(count);
|
|
||||||
for _ in 0..count {
|
|
||||||
vals.push(stack.pop().ok_or("stack underflow")?);
|
|
||||||
}
|
|
||||||
vals.reverse();
|
|
||||||
vals
|
|
||||||
}
|
|
||||||
_ => return Err("at expects float or int count".into()),
|
|
||||||
};
|
|
||||||
cmd.set_deltas(deltas);
|
cmd.set_deltas(deltas);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,6 +951,10 @@ impl Forth {
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
stack.push(Value::Int(val as i64, None));
|
stack.push(Value::Int(val as i64, None));
|
||||||
}
|
}
|
||||||
|
Op::Forget => {
|
||||||
|
let name = stack.pop().ok_or("stack underflow")?.as_str()?.to_string();
|
||||||
|
self.dict.lock().unwrap().remove(&name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pc += 1;
|
pc += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,46 @@ pub const WORDS: &[Word] = &[
|
|||||||
compile: Simple,
|
compile: Simple,
|
||||||
varargs: false,
|
varargs: false,
|
||||||
},
|
},
|
||||||
|
Word {
|
||||||
|
name: "2dup",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Stack",
|
||||||
|
stack: "(a b -- a b a b)",
|
||||||
|
desc: "Duplicate top two values",
|
||||||
|
example: "1 2 2dup => 1 2 1 2",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: false,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "2drop",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Stack",
|
||||||
|
stack: "(a b --)",
|
||||||
|
desc: "Drop top two values",
|
||||||
|
example: "1 2 3 2drop => 1",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: false,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "2swap",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Stack",
|
||||||
|
stack: "(a b c d -- c d a b)",
|
||||||
|
desc: "Swap top two pairs",
|
||||||
|
example: "1 2 3 4 2swap => 3 4 1 2",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: false,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "2over",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Stack",
|
||||||
|
stack: "(a b c d -- a b c d a b)",
|
||||||
|
desc: "Copy second pair to top",
|
||||||
|
example: "1 2 3 4 2over => 1 2 3 4 1 2",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: false,
|
||||||
|
},
|
||||||
// Arithmetic
|
// Arithmetic
|
||||||
Word {
|
Word {
|
||||||
name: "+",
|
name: "+",
|
||||||
@@ -473,6 +513,26 @@ pub const WORDS: &[Word] = &[
|
|||||||
compile: Simple,
|
compile: Simple,
|
||||||
varargs: false,
|
varargs: false,
|
||||||
},
|
},
|
||||||
|
Word {
|
||||||
|
name: "exprand",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Probability",
|
||||||
|
stack: "(lo hi -- f)",
|
||||||
|
desc: "Exponential random biased toward lo. Both args must be positive",
|
||||||
|
example: "1.0 100.0 exprand => 3.7",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: false,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "logrand",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Probability",
|
||||||
|
stack: "(lo hi -- f)",
|
||||||
|
desc: "Exponential random biased toward hi. Both args must be positive",
|
||||||
|
example: "1.0 100.0 logrand => 87.2",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: false,
|
||||||
|
},
|
||||||
Word {
|
Word {
|
||||||
name: "seed",
|
name: "seed",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
@@ -543,16 +603,6 @@ pub const WORDS: &[Word] = &[
|
|||||||
compile: Simple,
|
compile: Simple,
|
||||||
varargs: true,
|
varargs: true,
|
||||||
},
|
},
|
||||||
Word {
|
|
||||||
name: "tcycle",
|
|
||||||
aliases: &[],
|
|
||||||
category: "Probability",
|
|
||||||
stack: "(v1..vn n -- CycleList)",
|
|
||||||
desc: "Create cycle list for emit-time resolution",
|
|
||||||
example: "60 64 67 3 tcycle note",
|
|
||||||
compile: Simple,
|
|
||||||
varargs: true,
|
|
||||||
},
|
|
||||||
Word {
|
Word {
|
||||||
name: "every",
|
name: "every",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
@@ -810,6 +860,292 @@ pub const WORDS: &[Word] = &[
|
|||||||
compile: Simple,
|
compile: Simple,
|
||||||
varargs: false,
|
varargs: false,
|
||||||
},
|
},
|
||||||
|
// Chords - Triads
|
||||||
|
Word {
|
||||||
|
name: "maj",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth)",
|
||||||
|
desc: "Major triad",
|
||||||
|
example: "c4 maj => 60 64 67",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "m",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth)",
|
||||||
|
desc: "Minor triad",
|
||||||
|
example: "c4 m => 60 63 67",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "dim",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth)",
|
||||||
|
desc: "Diminished triad",
|
||||||
|
example: "c4 dim => 60 63 66",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "aug",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth)",
|
||||||
|
desc: "Augmented triad",
|
||||||
|
example: "c4 aug => 60 64 68",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "sus2",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root second fifth)",
|
||||||
|
desc: "Suspended 2nd",
|
||||||
|
example: "c4 sus2 => 60 62 67",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "sus4",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root fourth fifth)",
|
||||||
|
desc: "Suspended 4th",
|
||||||
|
example: "c4 sus4 => 60 65 67",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
// Chords - Seventh
|
||||||
|
Word {
|
||||||
|
name: "maj7",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth seventh)",
|
||||||
|
desc: "Major 7th",
|
||||||
|
example: "c4 maj7 => 60 64 67 71",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "min7",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth seventh)",
|
||||||
|
desc: "Minor 7th",
|
||||||
|
example: "c4 min7 => 60 63 67 70",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "dom7",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth seventh)",
|
||||||
|
desc: "Dominant 7th",
|
||||||
|
example: "c4 dom7 => 60 64 67 70",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "dim7",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth seventh)",
|
||||||
|
desc: "Diminished 7th",
|
||||||
|
example: "c4 dim7 => 60 63 66 69",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "m7b5",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth seventh)",
|
||||||
|
desc: "Half-diminished (min7b5)",
|
||||||
|
example: "c4 m7b5 => 60 63 66 70",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "minmaj7",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth seventh)",
|
||||||
|
desc: "Minor-major 7th",
|
||||||
|
example: "c4 minmaj7 => 60 63 67 71",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "aug7",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth seventh)",
|
||||||
|
desc: "Augmented 7th",
|
||||||
|
example: "c4 aug7 => 60 64 68 70",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
// Chords - Sixth
|
||||||
|
Word {
|
||||||
|
name: "maj6",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth sixth)",
|
||||||
|
desc: "Major 6th",
|
||||||
|
example: "c4 maj6 => 60 64 67 69",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "min6",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth sixth)",
|
||||||
|
desc: "Minor 6th",
|
||||||
|
example: "c4 min6 => 60 63 67 69",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
// Chords - Extended
|
||||||
|
Word {
|
||||||
|
name: "dom9",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth seventh ninth)",
|
||||||
|
desc: "Dominant 9th",
|
||||||
|
example: "c4 dom9 => 60 64 67 70 74",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "maj9",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth seventh ninth)",
|
||||||
|
desc: "Major 9th",
|
||||||
|
example: "c4 maj9 => 60 64 67 71 74",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "min9",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth seventh ninth)",
|
||||||
|
desc: "Minor 9th",
|
||||||
|
example: "c4 min9 => 60 63 67 70 74",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "dom11",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth seventh ninth eleventh)",
|
||||||
|
desc: "Dominant 11th",
|
||||||
|
example: "c4 dom11 => 60 64 67 70 74 77",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "min11",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth seventh ninth eleventh)",
|
||||||
|
desc: "Minor 11th",
|
||||||
|
example: "c4 min11 => 60 63 67 70 74 77",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "dom13",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth seventh ninth thirteenth)",
|
||||||
|
desc: "Dominant 13th",
|
||||||
|
example: "c4 dom13 => 60 64 67 70 74 81",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
// Chords - Add
|
||||||
|
Word {
|
||||||
|
name: "add9",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth ninth)",
|
||||||
|
desc: "Major add 9",
|
||||||
|
example: "c4 add9 => 60 64 67 74",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "add11",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth eleventh)",
|
||||||
|
desc: "Major add 11",
|
||||||
|
example: "c4 add11 => 60 64 67 77",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "madd9",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth ninth)",
|
||||||
|
desc: "Minor add 9",
|
||||||
|
example: "c4 madd9 => 60 63 67 74",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
// Chords - Altered dominants
|
||||||
|
Word {
|
||||||
|
name: "dom7b9",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth seventh flatninth)",
|
||||||
|
desc: "7th flat 9",
|
||||||
|
example: "c4 dom7b9 => 60 64 67 70 73",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "dom7s9",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third fifth seventh sharpninth)",
|
||||||
|
desc: "7th sharp 9 (Hendrix chord)",
|
||||||
|
example: "c4 dom7s9 => 60 64 67 70 75",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "dom7b5",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third flatfifth seventh)",
|
||||||
|
desc: "7th flat 5",
|
||||||
|
example: "c4 dom7b5 => 60 64 66 70",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "dom7s5",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Chord",
|
||||||
|
stack: "(root -- root third sharpfifth seventh)",
|
||||||
|
desc: "7th sharp 5",
|
||||||
|
example: "c4 dom7s5 => 60 64 68 70",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: true,
|
||||||
|
},
|
||||||
// LFO
|
// LFO
|
||||||
Word {
|
Word {
|
||||||
name: "ramp",
|
name: "ramp",
|
||||||
@@ -925,9 +1261,9 @@ pub const WORDS: &[Word] = &[
|
|||||||
name: "at",
|
name: "at",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
category: "Time",
|
category: "Time",
|
||||||
stack: "(v1..vn n --)",
|
stack: "(v1..vn --)",
|
||||||
desc: "Set delta context for emit timing",
|
desc: "Set delta context for emit timing",
|
||||||
example: "0 0.5 2 at kick s . => emits at 0 and 0.5 of step",
|
example: "0 0.5 at kick s . => emits at 0 and 0.5 of step",
|
||||||
compile: Simple,
|
compile: Simple,
|
||||||
varargs: true,
|
varargs: true,
|
||||||
},
|
},
|
||||||
@@ -2275,6 +2611,16 @@ pub const WORDS: &[Word] = &[
|
|||||||
compile: Simple,
|
compile: Simple,
|
||||||
varargs: false,
|
varargs: false,
|
||||||
},
|
},
|
||||||
|
Word {
|
||||||
|
name: "forget",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Definitions",
|
||||||
|
stack: "(name --)",
|
||||||
|
desc: "Remove user-defined word from dictionary",
|
||||||
|
example: "\"double\" forget",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: false,
|
||||||
|
},
|
||||||
// Generator
|
// Generator
|
||||||
Word {
|
Word {
|
||||||
name: "..",
|
name: "..",
|
||||||
@@ -2474,6 +2820,10 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
|
|||||||
"rot" => Op::Rot,
|
"rot" => Op::Rot,
|
||||||
"nip" => Op::Nip,
|
"nip" => Op::Nip,
|
||||||
"tuck" => Op::Tuck,
|
"tuck" => Op::Tuck,
|
||||||
|
"2dup" => Op::Dup2,
|
||||||
|
"2drop" => Op::Drop2,
|
||||||
|
"2swap" => Op::Swap2,
|
||||||
|
"2over" => Op::Over2,
|
||||||
"+" => Op::Add,
|
"+" => Op::Add,
|
||||||
"-" => Op::Sub,
|
"-" => Op::Sub,
|
||||||
"*" => Op::Mul,
|
"*" => Op::Mul,
|
||||||
@@ -2508,10 +2858,11 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
|
|||||||
"sound" => Op::NewCmd,
|
"sound" => Op::NewCmd,
|
||||||
"." => Op::Emit,
|
"." => Op::Emit,
|
||||||
"rand" => Op::Rand,
|
"rand" => Op::Rand,
|
||||||
|
"exprand" => Op::ExpRand,
|
||||||
|
"logrand" => Op::LogRand,
|
||||||
"seed" => Op::Seed,
|
"seed" => Op::Seed,
|
||||||
"cycle" => Op::Cycle,
|
"cycle" => Op::Cycle,
|
||||||
"pcycle" => Op::PCycle,
|
"pcycle" => Op::PCycle,
|
||||||
"tcycle" => Op::TCycle,
|
|
||||||
"choose" => Op::Choose,
|
"choose" => Op::Choose,
|
||||||
"every" => Op::Every,
|
"every" => Op::Every,
|
||||||
"chance" => Op::ChanceExec,
|
"chance" => Op::ChanceExec,
|
||||||
@@ -2545,6 +2896,7 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
|
|||||||
"mstart" => Op::MidiStart,
|
"mstart" => Op::MidiStart,
|
||||||
"mstop" => Op::MidiStop,
|
"mstop" => Op::MidiStop,
|
||||||
"mcont" => Op::MidiContinue,
|
"mcont" => Op::MidiContinue,
|
||||||
|
"forget" => Op::Forget,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2654,6 +3006,11 @@ pub(super) fn compile_word(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(intervals) = theory::chords::lookup(name) {
|
||||||
|
ops.push(Op::Chord(intervals));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(word) = lookup_word(name) {
|
if let Some(word) = lookup_word(name) {
|
||||||
match &word.compile {
|
match &word.compile {
|
||||||
Simple => {
|
Simple => {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ struct ProjectFile {
|
|||||||
sample_paths: Vec<PathBuf>,
|
sample_paths: Vec<PathBuf>,
|
||||||
#[serde(default = "default_tempo")]
|
#[serde(default = "default_tempo")]
|
||||||
tempo: f64,
|
tempo: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
playing_patterns: Vec<(usize, usize)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_tempo() -> f64 {
|
fn default_tempo() -> f64 {
|
||||||
@@ -38,6 +40,7 @@ impl From<&Project> for ProjectFile {
|
|||||||
banks: project.banks.clone(),
|
banks: project.banks.clone(),
|
||||||
sample_paths: project.sample_paths.clone(),
|
sample_paths: project.sample_paths.clone(),
|
||||||
tempo: project.tempo,
|
tempo: project.tempo,
|
||||||
|
playing_patterns: project.playing_patterns.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,6 +51,7 @@ impl From<ProjectFile> for Project {
|
|||||||
banks: file.banks,
|
banks: file.banks,
|
||||||
sample_paths: file.sample_paths,
|
sample_paths: file.sample_paths,
|
||||||
tempo: file.tempo,
|
tempo: file.tempo,
|
||||||
|
playing_patterns: file.playing_patterns,
|
||||||
};
|
};
|
||||||
project.normalize();
|
project.normalize();
|
||||||
project
|
project
|
||||||
|
|||||||
@@ -450,6 +450,8 @@ pub struct Project {
|
|||||||
pub sample_paths: Vec<PathBuf>,
|
pub sample_paths: Vec<PathBuf>,
|
||||||
#[serde(default = "default_tempo")]
|
#[serde(default = "default_tempo")]
|
||||||
pub tempo: f64,
|
pub tempo: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub playing_patterns: Vec<(usize, usize)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_tempo() -> f64 {
|
fn default_tempo() -> f64 {
|
||||||
@@ -462,6 +464,7 @@ impl Default for Project {
|
|||||||
banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(),
|
banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(),
|
||||||
sample_paths: Vec::new(),
|
sample_paths: Vec::new(),
|
||||||
tempo: default_tempo(),
|
tempo: default_tempo(),
|
||||||
|
playing_patterns: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
73
crates/ratatui/src/active_patterns.rs
Normal file
73
crates/ratatui/src/active_patterns.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
use crate::theme;
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::widgets::Widget;
|
||||||
|
|
||||||
|
pub struct ActivePatterns<'a> {
|
||||||
|
patterns: &'a [(usize, usize, usize)], // (bank, pattern, iter)
|
||||||
|
current_step: Option<(usize, usize)>, // (current_step, total_steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ActivePatterns<'a> {
|
||||||
|
pub fn new(patterns: &'a [(usize, usize, usize)]) -> Self {
|
||||||
|
Self {
|
||||||
|
patterns,
|
||||||
|
current_step: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_step(mut self, current: usize, total: usize) -> Self {
|
||||||
|
self.current_step = Some((current, total));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for ActivePatterns<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
if area.width < 10 || area.height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let theme = theme::get();
|
||||||
|
|
||||||
|
let max_pattern_rows = if self.current_step.is_some() {
|
||||||
|
area.height.saturating_sub(1) as usize
|
||||||
|
} else {
|
||||||
|
area.height as usize
|
||||||
|
};
|
||||||
|
|
||||||
|
for (row, &(bank, pattern, iter)) in self.patterns.iter().enumerate() {
|
||||||
|
if row >= max_pattern_rows {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let text = format!("B{:02}:{:02} ({:02})", bank + 1, pattern + 1, iter.min(99));
|
||||||
|
let y = area.y + row as u16;
|
||||||
|
let bg = if row % 2 == 0 {
|
||||||
|
theme.table.row_even
|
||||||
|
} else {
|
||||||
|
theme.table.row_odd
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut chars = text.chars();
|
||||||
|
for col in 0..area.width as usize {
|
||||||
|
let ch = chars.next().unwrap_or(' ');
|
||||||
|
buf[(area.x + col as u16, y)]
|
||||||
|
.set_char(ch)
|
||||||
|
.set_fg(theme.ui.text_primary)
|
||||||
|
.set_bg(bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((current, total)) = self.current_step {
|
||||||
|
let text = format!("{:02}/{:02}", current + 1, total);
|
||||||
|
let y = area.y + area.height.saturating_sub(1);
|
||||||
|
let mut chars = text.chars();
|
||||||
|
for col in 0..area.width as usize {
|
||||||
|
let ch = chars.next().unwrap_or(' ');
|
||||||
|
buf[(area.x + col as u16, y)]
|
||||||
|
.set_char(ch)
|
||||||
|
.set_fg(theme.ui.text_primary)
|
||||||
|
.set_bg(theme.table.row_even);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod active_patterns;
|
||||||
mod confirm;
|
mod confirm;
|
||||||
mod editor;
|
mod editor;
|
||||||
mod file_browser;
|
mod file_browser;
|
||||||
@@ -12,6 +13,7 @@ mod text_input;
|
|||||||
pub mod theme;
|
pub mod theme;
|
||||||
mod vu_meter;
|
mod vu_meter;
|
||||||
|
|
||||||
|
pub use active_patterns::ActivePatterns;
|
||||||
pub use confirm::ConfirmModal;
|
pub use confirm::ConfirmModal;
|
||||||
pub use editor::{CompletionCandidate, Editor};
|
pub use editor::{CompletionCandidate, Editor};
|
||||||
pub use file_browser::FileBrowserModal;
|
pub use file_browser::FileBrowserModal;
|
||||||
|
|||||||
277
crates/ratatui/src/theme/fairyfloss.rs
Normal file
277
crates/ratatui/src/theme/fairyfloss.rs
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
use super::*;
|
||||||
|
use ratatui::style::Color;
|
||||||
|
|
||||||
|
pub fn theme() -> ThemeColors {
|
||||||
|
let bg = Color::Rgb(90, 84, 117);
|
||||||
|
let bg_light = Color::Rgb(113, 103, 153);
|
||||||
|
let bg_lighter = Color::Rgb(130, 120, 165);
|
||||||
|
let fg = Color::Rgb(248, 248, 240);
|
||||||
|
let fg_dim = Color::Rgb(197, 163, 255);
|
||||||
|
let muted = Color::Rgb(168, 164, 177);
|
||||||
|
let dark = Color::Rgb(55, 51, 72);
|
||||||
|
|
||||||
|
let purple = Color::Rgb(174, 129, 255);
|
||||||
|
let pink = Color::Rgb(255, 184, 209);
|
||||||
|
let coral = Color::Rgb(255, 133, 127);
|
||||||
|
let yellow = Color::Rgb(255, 243, 82);
|
||||||
|
let gold = Color::Rgb(230, 192, 0);
|
||||||
|
let mint = Color::Rgb(194, 255, 223);
|
||||||
|
let lavender = Color::Rgb(197, 163, 255);
|
||||||
|
|
||||||
|
ThemeColors {
|
||||||
|
ui: UiColors {
|
||||||
|
bg,
|
||||||
|
bg_rgb: (90, 84, 117),
|
||||||
|
text_primary: fg,
|
||||||
|
text_muted: fg_dim,
|
||||||
|
text_dim: muted,
|
||||||
|
border: bg_lighter,
|
||||||
|
header: mint,
|
||||||
|
unfocused: muted,
|
||||||
|
accent: pink,
|
||||||
|
surface: bg_light,
|
||||||
|
},
|
||||||
|
status: StatusColors {
|
||||||
|
playing_bg: Color::Rgb(70, 95, 85),
|
||||||
|
playing_fg: mint,
|
||||||
|
stopped_bg: Color::Rgb(100, 70, 85),
|
||||||
|
stopped_fg: coral,
|
||||||
|
fill_on: mint,
|
||||||
|
fill_off: muted,
|
||||||
|
fill_bg: bg_light,
|
||||||
|
},
|
||||||
|
selection: SelectionColors {
|
||||||
|
cursor_bg: pink,
|
||||||
|
cursor_fg: dark,
|
||||||
|
selected_bg: Color::Rgb(120, 90, 130),
|
||||||
|
selected_fg: pink,
|
||||||
|
in_range_bg: Color::Rgb(100, 95, 125),
|
||||||
|
in_range_fg: fg,
|
||||||
|
cursor: pink,
|
||||||
|
selected: Color::Rgb(120, 90, 130),
|
||||||
|
in_range: Color::Rgb(100, 95, 125),
|
||||||
|
},
|
||||||
|
tile: TileColors {
|
||||||
|
playing_active_bg: Color::Rgb(100, 85, 60),
|
||||||
|
playing_active_fg: gold,
|
||||||
|
playing_inactive_bg: Color::Rgb(95, 90, 70),
|
||||||
|
playing_inactive_fg: yellow,
|
||||||
|
active_bg: Color::Rgb(70, 100, 100),
|
||||||
|
active_fg: mint,
|
||||||
|
inactive_bg: bg_light,
|
||||||
|
inactive_fg: fg_dim,
|
||||||
|
active_selected_bg: Color::Rgb(120, 90, 130),
|
||||||
|
active_in_range_bg: Color::Rgb(100, 95, 125),
|
||||||
|
link_bright: [
|
||||||
|
(255, 184, 209),
|
||||||
|
(174, 129, 255),
|
||||||
|
(255, 133, 127),
|
||||||
|
(194, 255, 223),
|
||||||
|
(255, 243, 82),
|
||||||
|
],
|
||||||
|
link_dim: [
|
||||||
|
(100, 75, 90),
|
||||||
|
(85, 70, 105),
|
||||||
|
(100, 65, 65),
|
||||||
|
(75, 100, 95),
|
||||||
|
(100, 95, 55),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
header: HeaderColors {
|
||||||
|
tempo_bg: Color::Rgb(100, 75, 95),
|
||||||
|
tempo_fg: pink,
|
||||||
|
bank_bg: Color::Rgb(70, 95, 95),
|
||||||
|
bank_fg: mint,
|
||||||
|
pattern_bg: Color::Rgb(85, 75, 110),
|
||||||
|
pattern_fg: purple,
|
||||||
|
stats_bg: bg_light,
|
||||||
|
stats_fg: fg_dim,
|
||||||
|
},
|
||||||
|
modal: ModalColors {
|
||||||
|
border: mint,
|
||||||
|
border_accent: pink,
|
||||||
|
border_warn: coral,
|
||||||
|
border_dim: muted,
|
||||||
|
confirm: coral,
|
||||||
|
rename: purple,
|
||||||
|
input: mint,
|
||||||
|
editor: mint,
|
||||||
|
preview: muted,
|
||||||
|
},
|
||||||
|
flash: FlashColors {
|
||||||
|
error_bg: Color::Rgb(100, 65, 70),
|
||||||
|
error_fg: coral,
|
||||||
|
success_bg: Color::Rgb(65, 95, 85),
|
||||||
|
success_fg: mint,
|
||||||
|
info_bg: bg_light,
|
||||||
|
info_fg: fg,
|
||||||
|
event_rgb: (100, 85, 110),
|
||||||
|
},
|
||||||
|
list: ListColors {
|
||||||
|
playing_bg: Color::Rgb(65, 95, 85),
|
||||||
|
playing_fg: mint,
|
||||||
|
staged_play_bg: Color::Rgb(95, 80, 120),
|
||||||
|
staged_play_fg: purple,
|
||||||
|
staged_stop_bg: Color::Rgb(105, 70, 85),
|
||||||
|
staged_stop_fg: pink,
|
||||||
|
edit_bg: Color::Rgb(70, 95, 100),
|
||||||
|
edit_fg: mint,
|
||||||
|
hover_bg: bg_lighter,
|
||||||
|
hover_fg: fg,
|
||||||
|
},
|
||||||
|
link_status: LinkStatusColors {
|
||||||
|
disabled: coral,
|
||||||
|
connected: mint,
|
||||||
|
listening: yellow,
|
||||||
|
},
|
||||||
|
syntax: SyntaxColors {
|
||||||
|
gap_bg: dark,
|
||||||
|
executed_bg: Color::Rgb(80, 75, 100),
|
||||||
|
selected_bg: Color::Rgb(110, 100, 70),
|
||||||
|
emit: (fg, Color::Rgb(110, 80, 100)),
|
||||||
|
number: (purple, Color::Rgb(85, 75, 110)),
|
||||||
|
string: (yellow, Color::Rgb(100, 95, 60)),
|
||||||
|
comment: (muted, dark),
|
||||||
|
keyword: (pink, Color::Rgb(105, 75, 90)),
|
||||||
|
stack_op: (mint, Color::Rgb(70, 100, 95)),
|
||||||
|
operator: (pink, Color::Rgb(105, 75, 90)),
|
||||||
|
sound: (mint, Color::Rgb(70, 100, 95)),
|
||||||
|
param: (coral, Color::Rgb(105, 70, 70)),
|
||||||
|
context: (coral, Color::Rgb(105, 70, 70)),
|
||||||
|
note: (lavender, Color::Rgb(85, 75, 110)),
|
||||||
|
interval: (Color::Rgb(220, 190, 255), Color::Rgb(85, 75, 100)),
|
||||||
|
variable: (lavender, Color::Rgb(85, 75, 110)),
|
||||||
|
vary: (yellow, Color::Rgb(100, 95, 60)),
|
||||||
|
generator: (mint, Color::Rgb(70, 95, 95)),
|
||||||
|
default: (fg_dim, dark),
|
||||||
|
},
|
||||||
|
table: TableColors {
|
||||||
|
row_even: dark,
|
||||||
|
row_odd: bg,
|
||||||
|
},
|
||||||
|
values: ValuesColors {
|
||||||
|
tempo: coral,
|
||||||
|
value: fg_dim,
|
||||||
|
},
|
||||||
|
hint: HintColors {
|
||||||
|
key: coral,
|
||||||
|
text: muted,
|
||||||
|
},
|
||||||
|
view_badge: ViewBadgeColors { bg: fg, fg: bg },
|
||||||
|
nav: NavColors {
|
||||||
|
selected_bg: Color::Rgb(110, 85, 120),
|
||||||
|
selected_fg: fg,
|
||||||
|
unselected_bg: bg_light,
|
||||||
|
unselected_fg: muted,
|
||||||
|
},
|
||||||
|
editor_widget: EditorWidgetColors {
|
||||||
|
cursor_bg: fg,
|
||||||
|
cursor_fg: bg,
|
||||||
|
selection_bg: Color::Rgb(105, 95, 125),
|
||||||
|
completion_bg: bg_light,
|
||||||
|
completion_fg: fg,
|
||||||
|
completion_selected: coral,
|
||||||
|
completion_example: mint,
|
||||||
|
},
|
||||||
|
browser: BrowserColors {
|
||||||
|
directory: mint,
|
||||||
|
project_file: purple,
|
||||||
|
selected: coral,
|
||||||
|
file: fg,
|
||||||
|
focused_border: coral,
|
||||||
|
unfocused_border: muted,
|
||||||
|
root: fg,
|
||||||
|
file_icon: muted,
|
||||||
|
folder_icon: mint,
|
||||||
|
empty_text: muted,
|
||||||
|
},
|
||||||
|
input: InputColors {
|
||||||
|
text: mint,
|
||||||
|
cursor: fg,
|
||||||
|
hint: muted,
|
||||||
|
},
|
||||||
|
search: SearchColors {
|
||||||
|
active: coral,
|
||||||
|
inactive: muted,
|
||||||
|
match_bg: yellow,
|
||||||
|
match_fg: dark,
|
||||||
|
},
|
||||||
|
markdown: MarkdownColors {
|
||||||
|
h1: mint,
|
||||||
|
h2: coral,
|
||||||
|
h3: purple,
|
||||||
|
code: lavender,
|
||||||
|
code_border: Color::Rgb(120, 115, 140),
|
||||||
|
link: pink,
|
||||||
|
link_url: Color::Rgb(150, 145, 165),
|
||||||
|
quote: muted,
|
||||||
|
text: fg,
|
||||||
|
list: fg,
|
||||||
|
},
|
||||||
|
engine: EngineColors {
|
||||||
|
header: mint,
|
||||||
|
header_focused: yellow,
|
||||||
|
divider: Color::Rgb(110, 105, 130),
|
||||||
|
scroll_indicator: Color::Rgb(125, 120, 145),
|
||||||
|
label: Color::Rgb(175, 170, 190),
|
||||||
|
label_focused: Color::Rgb(210, 205, 225),
|
||||||
|
label_dim: Color::Rgb(145, 140, 160),
|
||||||
|
value: Color::Rgb(230, 225, 240),
|
||||||
|
focused: yellow,
|
||||||
|
normal: fg,
|
||||||
|
dim: Color::Rgb(125, 120, 145),
|
||||||
|
path: Color::Rgb(175, 170, 190),
|
||||||
|
border_magenta: pink,
|
||||||
|
border_green: mint,
|
||||||
|
border_cyan: lavender,
|
||||||
|
separator: Color::Rgb(110, 105, 130),
|
||||||
|
hint_active: Color::Rgb(240, 230, 120),
|
||||||
|
hint_inactive: Color::Rgb(110, 105, 130),
|
||||||
|
},
|
||||||
|
dict: DictColors {
|
||||||
|
word_name: lavender,
|
||||||
|
word_bg: Color::Rgb(75, 85, 105),
|
||||||
|
alias: muted,
|
||||||
|
stack_sig: purple,
|
||||||
|
description: fg,
|
||||||
|
example: Color::Rgb(175, 170, 190),
|
||||||
|
category_focused: yellow,
|
||||||
|
category_selected: mint,
|
||||||
|
category_normal: fg,
|
||||||
|
category_dimmed: Color::Rgb(125, 120, 145),
|
||||||
|
border_focused: yellow,
|
||||||
|
border_normal: Color::Rgb(110, 105, 130),
|
||||||
|
header_desc: Color::Rgb(195, 190, 210),
|
||||||
|
},
|
||||||
|
title: TitleColors {
|
||||||
|
big_title: pink,
|
||||||
|
author: mint,
|
||||||
|
link: lavender,
|
||||||
|
license: coral,
|
||||||
|
prompt: Color::Rgb(195, 190, 210),
|
||||||
|
subtitle: fg,
|
||||||
|
},
|
||||||
|
meter: MeterColors {
|
||||||
|
low: mint,
|
||||||
|
mid: yellow,
|
||||||
|
high: coral,
|
||||||
|
low_rgb: (194, 255, 223),
|
||||||
|
mid_rgb: (255, 243, 82),
|
||||||
|
high_rgb: (255, 133, 127),
|
||||||
|
},
|
||||||
|
sparkle: SparkleColors {
|
||||||
|
colors: [
|
||||||
|
(194, 255, 223),
|
||||||
|
(255, 133, 127),
|
||||||
|
(255, 243, 82),
|
||||||
|
(255, 184, 209),
|
||||||
|
(174, 129, 255),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
confirm: ConfirmColors {
|
||||||
|
border: coral,
|
||||||
|
button_selected_bg: coral,
|
||||||
|
button_selected_fg: dark,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
273
crates/ratatui/src/theme/hot_dog_stand.rs
Normal file
273
crates/ratatui/src/theme/hot_dog_stand.rs
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
use super::*;
|
||||||
|
use ratatui::style::Color;
|
||||||
|
|
||||||
|
pub fn theme() -> ThemeColors {
|
||||||
|
let red = Color::Rgb(255, 0, 0);
|
||||||
|
let dark_red = Color::Rgb(215, 0, 0);
|
||||||
|
let darker_red = Color::Rgb(175, 0, 0);
|
||||||
|
let yellow = Color::Rgb(255, 255, 0);
|
||||||
|
let light_yellow = Color::Rgb(255, 255, 95);
|
||||||
|
let gold = Color::Rgb(255, 215, 0);
|
||||||
|
let black = Color::Rgb(0, 0, 0);
|
||||||
|
let white = Color::Rgb(255, 255, 255);
|
||||||
|
|
||||||
|
let dim_yellow = Color::Rgb(180, 180, 0);
|
||||||
|
let muted_red = Color::Rgb(140, 40, 40);
|
||||||
|
|
||||||
|
ThemeColors {
|
||||||
|
ui: UiColors {
|
||||||
|
bg: red,
|
||||||
|
bg_rgb: (255, 0, 0),
|
||||||
|
text_primary: yellow,
|
||||||
|
text_muted: light_yellow,
|
||||||
|
text_dim: gold,
|
||||||
|
border: yellow,
|
||||||
|
header: yellow,
|
||||||
|
unfocused: gold,
|
||||||
|
accent: yellow,
|
||||||
|
surface: dark_red,
|
||||||
|
},
|
||||||
|
status: StatusColors {
|
||||||
|
playing_bg: Color::Rgb(180, 180, 0),
|
||||||
|
playing_fg: black,
|
||||||
|
stopped_bg: darker_red,
|
||||||
|
stopped_fg: yellow,
|
||||||
|
fill_on: yellow,
|
||||||
|
fill_off: gold,
|
||||||
|
fill_bg: dark_red,
|
||||||
|
},
|
||||||
|
selection: SelectionColors {
|
||||||
|
cursor_bg: yellow,
|
||||||
|
cursor_fg: red,
|
||||||
|
selected_bg: Color::Rgb(200, 200, 0),
|
||||||
|
selected_fg: black,
|
||||||
|
in_range_bg: Color::Rgb(170, 100, 0),
|
||||||
|
in_range_fg: yellow,
|
||||||
|
cursor: yellow,
|
||||||
|
selected: Color::Rgb(200, 200, 0),
|
||||||
|
in_range: Color::Rgb(170, 100, 0),
|
||||||
|
},
|
||||||
|
tile: TileColors {
|
||||||
|
playing_active_bg: Color::Rgb(200, 200, 0),
|
||||||
|
playing_active_fg: black,
|
||||||
|
playing_inactive_bg: Color::Rgb(180, 180, 0),
|
||||||
|
playing_inactive_fg: black,
|
||||||
|
active_bg: Color::Rgb(200, 50, 50),
|
||||||
|
active_fg: yellow,
|
||||||
|
inactive_bg: dark_red,
|
||||||
|
inactive_fg: gold,
|
||||||
|
active_selected_bg: Color::Rgb(200, 200, 0),
|
||||||
|
active_in_range_bg: Color::Rgb(170, 100, 0),
|
||||||
|
link_bright: [
|
||||||
|
(255, 255, 0),
|
||||||
|
(255, 255, 255),
|
||||||
|
(255, 215, 0),
|
||||||
|
(255, 255, 95),
|
||||||
|
(255, 255, 0),
|
||||||
|
],
|
||||||
|
link_dim: [
|
||||||
|
(140, 140, 0),
|
||||||
|
(140, 140, 140),
|
||||||
|
(140, 120, 0),
|
||||||
|
(140, 140, 60),
|
||||||
|
(140, 140, 0),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
header: HeaderColors {
|
||||||
|
tempo_bg: Color::Rgb(180, 180, 0),
|
||||||
|
tempo_fg: black,
|
||||||
|
bank_bg: darker_red,
|
||||||
|
bank_fg: yellow,
|
||||||
|
pattern_bg: Color::Rgb(200, 200, 0),
|
||||||
|
pattern_fg: black,
|
||||||
|
stats_bg: dark_red,
|
||||||
|
stats_fg: yellow,
|
||||||
|
},
|
||||||
|
modal: ModalColors {
|
||||||
|
border: yellow,
|
||||||
|
border_accent: white,
|
||||||
|
border_warn: gold,
|
||||||
|
border_dim: dim_yellow,
|
||||||
|
confirm: gold,
|
||||||
|
rename: light_yellow,
|
||||||
|
input: yellow,
|
||||||
|
editor: yellow,
|
||||||
|
preview: gold,
|
||||||
|
},
|
||||||
|
flash: FlashColors {
|
||||||
|
error_bg: black,
|
||||||
|
error_fg: yellow,
|
||||||
|
success_bg: Color::Rgb(180, 180, 0),
|
||||||
|
success_fg: black,
|
||||||
|
info_bg: dark_red,
|
||||||
|
info_fg: yellow,
|
||||||
|
event_rgb: (170, 100, 0),
|
||||||
|
},
|
||||||
|
list: ListColors {
|
||||||
|
playing_bg: Color::Rgb(180, 180, 0),
|
||||||
|
playing_fg: black,
|
||||||
|
staged_play_bg: Color::Rgb(200, 200, 0),
|
||||||
|
staged_play_fg: black,
|
||||||
|
staged_stop_bg: darker_red,
|
||||||
|
staged_stop_fg: yellow,
|
||||||
|
edit_bg: Color::Rgb(200, 50, 50),
|
||||||
|
edit_fg: yellow,
|
||||||
|
hover_bg: Color::Rgb(230, 50, 50),
|
||||||
|
hover_fg: yellow,
|
||||||
|
},
|
||||||
|
link_status: LinkStatusColors {
|
||||||
|
disabled: white,
|
||||||
|
connected: yellow,
|
||||||
|
listening: gold,
|
||||||
|
},
|
||||||
|
syntax: SyntaxColors {
|
||||||
|
gap_bg: darker_red,
|
||||||
|
executed_bg: Color::Rgb(200, 50, 50),
|
||||||
|
selected_bg: Color::Rgb(180, 180, 0),
|
||||||
|
emit: (yellow, muted_red),
|
||||||
|
number: (white, muted_red),
|
||||||
|
string: (gold, muted_red),
|
||||||
|
comment: (dim_yellow, darker_red),
|
||||||
|
keyword: (light_yellow, muted_red),
|
||||||
|
stack_op: (yellow, muted_red),
|
||||||
|
operator: (light_yellow, muted_red),
|
||||||
|
sound: (yellow, muted_red),
|
||||||
|
param: (gold, muted_red),
|
||||||
|
context: (gold, muted_red),
|
||||||
|
note: (white, muted_red),
|
||||||
|
interval: (Color::Rgb(255, 240, 150), muted_red),
|
||||||
|
variable: (white, muted_red),
|
||||||
|
vary: (gold, muted_red),
|
||||||
|
generator: (yellow, muted_red),
|
||||||
|
default: (light_yellow, darker_red),
|
||||||
|
},
|
||||||
|
table: TableColors {
|
||||||
|
row_even: darker_red,
|
||||||
|
row_odd: red,
|
||||||
|
},
|
||||||
|
values: ValuesColors {
|
||||||
|
tempo: gold,
|
||||||
|
value: light_yellow,
|
||||||
|
},
|
||||||
|
hint: HintColors {
|
||||||
|
key: white,
|
||||||
|
text: gold,
|
||||||
|
},
|
||||||
|
view_badge: ViewBadgeColors { bg: yellow, fg: red },
|
||||||
|
nav: NavColors {
|
||||||
|
selected_bg: Color::Rgb(200, 200, 0),
|
||||||
|
selected_fg: black,
|
||||||
|
unselected_bg: dark_red,
|
||||||
|
unselected_fg: gold,
|
||||||
|
},
|
||||||
|
editor_widget: EditorWidgetColors {
|
||||||
|
cursor_bg: yellow,
|
||||||
|
cursor_fg: red,
|
||||||
|
selection_bg: Color::Rgb(180, 180, 0),
|
||||||
|
completion_bg: dark_red,
|
||||||
|
completion_fg: yellow,
|
||||||
|
completion_selected: white,
|
||||||
|
completion_example: gold,
|
||||||
|
},
|
||||||
|
browser: BrowserColors {
|
||||||
|
directory: yellow,
|
||||||
|
project_file: white,
|
||||||
|
selected: gold,
|
||||||
|
file: light_yellow,
|
||||||
|
focused_border: white,
|
||||||
|
unfocused_border: gold,
|
||||||
|
root: yellow,
|
||||||
|
file_icon: gold,
|
||||||
|
folder_icon: yellow,
|
||||||
|
empty_text: gold,
|
||||||
|
},
|
||||||
|
input: InputColors {
|
||||||
|
text: yellow,
|
||||||
|
cursor: white,
|
||||||
|
hint: gold,
|
||||||
|
},
|
||||||
|
search: SearchColors {
|
||||||
|
active: white,
|
||||||
|
inactive: gold,
|
||||||
|
match_bg: yellow,
|
||||||
|
match_fg: red,
|
||||||
|
},
|
||||||
|
markdown: MarkdownColors {
|
||||||
|
h1: yellow,
|
||||||
|
h2: white,
|
||||||
|
h3: gold,
|
||||||
|
code: light_yellow,
|
||||||
|
code_border: dim_yellow,
|
||||||
|
link: white,
|
||||||
|
link_url: gold,
|
||||||
|
quote: dim_yellow,
|
||||||
|
text: yellow,
|
||||||
|
list: yellow,
|
||||||
|
},
|
||||||
|
engine: EngineColors {
|
||||||
|
header: yellow,
|
||||||
|
header_focused: white,
|
||||||
|
divider: dim_yellow,
|
||||||
|
scroll_indicator: gold,
|
||||||
|
label: light_yellow,
|
||||||
|
label_focused: white,
|
||||||
|
label_dim: dim_yellow,
|
||||||
|
value: yellow,
|
||||||
|
focused: white,
|
||||||
|
normal: yellow,
|
||||||
|
dim: dim_yellow,
|
||||||
|
path: gold,
|
||||||
|
border_magenta: gold,
|
||||||
|
border_green: yellow,
|
||||||
|
border_cyan: white,
|
||||||
|
separator: dim_yellow,
|
||||||
|
hint_active: white,
|
||||||
|
hint_inactive: dim_yellow,
|
||||||
|
},
|
||||||
|
dict: DictColors {
|
||||||
|
word_name: yellow,
|
||||||
|
word_bg: darker_red,
|
||||||
|
alias: gold,
|
||||||
|
stack_sig: white,
|
||||||
|
description: yellow,
|
||||||
|
example: gold,
|
||||||
|
category_focused: white,
|
||||||
|
category_selected: yellow,
|
||||||
|
category_normal: light_yellow,
|
||||||
|
category_dimmed: dim_yellow,
|
||||||
|
border_focused: white,
|
||||||
|
border_normal: dim_yellow,
|
||||||
|
header_desc: gold,
|
||||||
|
},
|
||||||
|
title: TitleColors {
|
||||||
|
big_title: yellow,
|
||||||
|
author: white,
|
||||||
|
link: gold,
|
||||||
|
license: light_yellow,
|
||||||
|
prompt: gold,
|
||||||
|
subtitle: yellow,
|
||||||
|
},
|
||||||
|
meter: MeterColors {
|
||||||
|
low: yellow,
|
||||||
|
mid: gold,
|
||||||
|
high: white,
|
||||||
|
low_rgb: (255, 255, 0),
|
||||||
|
mid_rgb: (255, 215, 0),
|
||||||
|
high_rgb: (255, 255, 255),
|
||||||
|
},
|
||||||
|
sparkle: SparkleColors {
|
||||||
|
colors: [
|
||||||
|
(255, 255, 0),
|
||||||
|
(255, 255, 255),
|
||||||
|
(255, 215, 0),
|
||||||
|
(255, 255, 95),
|
||||||
|
(255, 255, 0),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
confirm: ConfirmColors {
|
||||||
|
border: white,
|
||||||
|
button_selected_bg: yellow,
|
||||||
|
button_selected_fg: red,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,8 +14,8 @@ pub fn theme() -> ThemeColors {
|
|||||||
let autumn_red = Color::Rgb(195, 64, 67);
|
let autumn_red = Color::Rgb(195, 64, 67);
|
||||||
let carp_yellow = Color::Rgb(230, 195, 132);
|
let carp_yellow = Color::Rgb(230, 195, 132);
|
||||||
let spring_blue = Color::Rgb(127, 180, 202);
|
let spring_blue = Color::Rgb(127, 180, 202);
|
||||||
let wave_red = Color::Rgb(226, 109, 115);
|
let wave_red = Color::Rgb(228, 104, 118);
|
||||||
let sakura_pink = Color::Rgb(212, 140, 149);
|
let sakura_pink = Color::Rgb(210, 126, 153);
|
||||||
|
|
||||||
let darker_bg = Color::Rgb(26, 26, 34);
|
let darker_bg = Color::Rgb(26, 26, 34);
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ pub fn theme() -> ThemeColors {
|
|||||||
active_selected_bg: Color::Rgb(65, 55, 70),
|
active_selected_bg: Color::Rgb(65, 55, 70),
|
||||||
active_in_range_bg: Color::Rgb(50, 50, 60),
|
active_in_range_bg: Color::Rgb(50, 50, 60),
|
||||||
link_bright: [
|
link_bright: [
|
||||||
(226, 109, 115),
|
(228, 104, 118),
|
||||||
(149, 127, 184),
|
(149, 127, 184),
|
||||||
(230, 195, 132),
|
(230, 195, 132),
|
||||||
(127, 180, 202),
|
(127, 180, 202),
|
||||||
@@ -258,14 +258,14 @@ pub fn theme() -> ThemeColors {
|
|||||||
high: wave_red,
|
high: wave_red,
|
||||||
low_rgb: (118, 148, 106),
|
low_rgb: (118, 148, 106),
|
||||||
mid_rgb: (230, 195, 132),
|
mid_rgb: (230, 195, 132),
|
||||||
high_rgb: (226, 109, 115),
|
high_rgb: (228, 104, 118),
|
||||||
},
|
},
|
||||||
sparkle: SparkleColors {
|
sparkle: SparkleColors {
|
||||||
colors: [
|
colors: [
|
||||||
(127, 180, 202),
|
(127, 180, 202),
|
||||||
(230, 195, 132),
|
(230, 195, 132),
|
||||||
(118, 148, 106),
|
(118, 148, 106),
|
||||||
(226, 109, 115),
|
(228, 104, 118),
|
||||||
(149, 127, 184),
|
(149, 127, 184),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
mod catppuccin_latte;
|
mod catppuccin_latte;
|
||||||
mod catppuccin_mocha;
|
mod catppuccin_mocha;
|
||||||
mod dracula;
|
mod dracula;
|
||||||
|
mod fairyfloss;
|
||||||
mod gruvbox_dark;
|
mod gruvbox_dark;
|
||||||
|
mod hot_dog_stand;
|
||||||
mod kanagawa;
|
mod kanagawa;
|
||||||
mod monochrome_black;
|
mod monochrome_black;
|
||||||
mod monochrome_white;
|
mod monochrome_white;
|
||||||
@@ -13,6 +15,7 @@ mod nord;
|
|||||||
mod pitch_black;
|
mod pitch_black;
|
||||||
mod rose_pine;
|
mod rose_pine;
|
||||||
mod tokyo_night;
|
mod tokyo_night;
|
||||||
|
pub mod transform;
|
||||||
|
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
@@ -36,6 +39,8 @@ pub const THEMES: &[ThemeEntry] = &[
|
|||||||
ThemeEntry { id: "TokyoNight", label: "Tokyo Night", colors: tokyo_night::theme },
|
ThemeEntry { id: "TokyoNight", label: "Tokyo Night", colors: tokyo_night::theme },
|
||||||
ThemeEntry { id: "RosePine", label: "Rosé Pine", colors: rose_pine::theme },
|
ThemeEntry { id: "RosePine", label: "Rosé Pine", colors: rose_pine::theme },
|
||||||
ThemeEntry { id: "Kanagawa", label: "Kanagawa", colors: kanagawa::theme },
|
ThemeEntry { id: "Kanagawa", label: "Kanagawa", colors: kanagawa::theme },
|
||||||
|
ThemeEntry { id: "Fairyfloss", label: "Fairyfloss", colors: fairyfloss::theme },
|
||||||
|
ThemeEntry { id: "HotDogStand", label: "Hot Dog Stand", colors: hot_dog_stand::theme },
|
||||||
];
|
];
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ pub fn theme() -> ThemeColors {
|
|||||||
let fg = Color::Rgb(224, 222, 244);
|
let fg = Color::Rgb(224, 222, 244);
|
||||||
let fg_dim = Color::Rgb(144, 140, 170);
|
let fg_dim = Color::Rgb(144, 140, 170);
|
||||||
let muted = Color::Rgb(110, 106, 134);
|
let muted = Color::Rgb(110, 106, 134);
|
||||||
let rose = Color::Rgb(235, 111, 146);
|
let rose = Color::Rgb(235, 188, 186);
|
||||||
let gold = Color::Rgb(246, 193, 119);
|
let gold = Color::Rgb(246, 193, 119);
|
||||||
let foam = Color::Rgb(156, 207, 216);
|
let foam = Color::Rgb(156, 207, 216);
|
||||||
let iris = Color::Rgb(196, 167, 231);
|
let iris = Color::Rgb(196, 167, 231);
|
||||||
|
|||||||
345
crates/ratatui/src/theme/transform.rs
Normal file
345
crates/ratatui/src/theme/transform.rs
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
use ratatui::style::Color;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn rgb_to_hsv(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
|
||||||
|
let r = r as f32 / 255.0;
|
||||||
|
let g = g as f32 / 255.0;
|
||||||
|
let b = b as f32 / 255.0;
|
||||||
|
|
||||||
|
let max = r.max(g).max(b);
|
||||||
|
let min = r.min(g).min(b);
|
||||||
|
let delta = max - min;
|
||||||
|
|
||||||
|
let h = if delta == 0.0 {
|
||||||
|
0.0
|
||||||
|
} else if max == r {
|
||||||
|
60.0 * (((g - b) / delta) % 6.0)
|
||||||
|
} else if max == g {
|
||||||
|
60.0 * (((b - r) / delta) + 2.0)
|
||||||
|
} else {
|
||||||
|
60.0 * (((r - g) / delta) + 4.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let h = if h < 0.0 { h + 360.0 } else { h };
|
||||||
|
let s = if max == 0.0 { 0.0 } else { delta / max };
|
||||||
|
let v = max;
|
||||||
|
|
||||||
|
(h, s, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
|
||||||
|
let c = v * s;
|
||||||
|
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
|
||||||
|
let m = v - c;
|
||||||
|
|
||||||
|
let (r, g, b) = if h < 60.0 {
|
||||||
|
(c, x, 0.0)
|
||||||
|
} else if h < 120.0 {
|
||||||
|
(x, c, 0.0)
|
||||||
|
} else if h < 180.0 {
|
||||||
|
(0.0, c, x)
|
||||||
|
} else if h < 240.0 {
|
||||||
|
(0.0, x, c)
|
||||||
|
} else if h < 300.0 {
|
||||||
|
(x, 0.0, c)
|
||||||
|
} else {
|
||||||
|
(c, 0.0, x)
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
((r + m) * 255.0) as u8,
|
||||||
|
((g + m) * 255.0) as u8,
|
||||||
|
((b + m) * 255.0) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate_hue_rgb(r: u8, g: u8, b: u8, degrees: f32) -> (u8, u8, u8) {
|
||||||
|
let (h, s, v) = rgb_to_hsv(r, g, b);
|
||||||
|
let new_h = (h + degrees) % 360.0;
|
||||||
|
let new_h = if new_h < 0.0 { new_h + 360.0 } else { new_h };
|
||||||
|
hsv_to_rgb(new_h, s, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate_color(color: Color, degrees: f32) -> Color {
|
||||||
|
match color {
|
||||||
|
Color::Rgb(r, g, b) => {
|
||||||
|
let (nr, ng, nb) = rotate_hue_rgb(r, g, b, degrees);
|
||||||
|
Color::Rgb(nr, ng, nb)
|
||||||
|
}
|
||||||
|
_ => color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate_tuple(tuple: (u8, u8, u8), degrees: f32) -> (u8, u8, u8) {
|
||||||
|
rotate_hue_rgb(tuple.0, tuple.1, tuple.2, degrees)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate_color_pair(pair: (Color, Color), degrees: f32) -> (Color, Color) {
|
||||||
|
(rotate_color(pair.0, degrees), rotate_color(pair.1, degrees))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rotate_theme(theme: ThemeColors, degrees: f32) -> ThemeColors {
|
||||||
|
if degrees == 0.0 {
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeColors {
|
||||||
|
ui: UiColors {
|
||||||
|
bg: rotate_color(theme.ui.bg, degrees),
|
||||||
|
bg_rgb: rotate_tuple(theme.ui.bg_rgb, degrees),
|
||||||
|
text_primary: rotate_color(theme.ui.text_primary, degrees),
|
||||||
|
text_muted: rotate_color(theme.ui.text_muted, degrees),
|
||||||
|
text_dim: rotate_color(theme.ui.text_dim, degrees),
|
||||||
|
border: rotate_color(theme.ui.border, degrees),
|
||||||
|
header: rotate_color(theme.ui.header, degrees),
|
||||||
|
unfocused: rotate_color(theme.ui.unfocused, degrees),
|
||||||
|
accent: rotate_color(theme.ui.accent, degrees),
|
||||||
|
surface: rotate_color(theme.ui.surface, degrees),
|
||||||
|
},
|
||||||
|
status: StatusColors {
|
||||||
|
playing_bg: rotate_color(theme.status.playing_bg, degrees),
|
||||||
|
playing_fg: rotate_color(theme.status.playing_fg, degrees),
|
||||||
|
stopped_bg: rotate_color(theme.status.stopped_bg, degrees),
|
||||||
|
stopped_fg: rotate_color(theme.status.stopped_fg, degrees),
|
||||||
|
fill_on: rotate_color(theme.status.fill_on, degrees),
|
||||||
|
fill_off: rotate_color(theme.status.fill_off, degrees),
|
||||||
|
fill_bg: rotate_color(theme.status.fill_bg, degrees),
|
||||||
|
},
|
||||||
|
selection: SelectionColors {
|
||||||
|
cursor_bg: rotate_color(theme.selection.cursor_bg, degrees),
|
||||||
|
cursor_fg: rotate_color(theme.selection.cursor_fg, degrees),
|
||||||
|
selected_bg: rotate_color(theme.selection.selected_bg, degrees),
|
||||||
|
selected_fg: rotate_color(theme.selection.selected_fg, degrees),
|
||||||
|
in_range_bg: rotate_color(theme.selection.in_range_bg, degrees),
|
||||||
|
in_range_fg: rotate_color(theme.selection.in_range_fg, degrees),
|
||||||
|
cursor: rotate_color(theme.selection.cursor, degrees),
|
||||||
|
selected: rotate_color(theme.selection.selected, degrees),
|
||||||
|
in_range: rotate_color(theme.selection.in_range, degrees),
|
||||||
|
},
|
||||||
|
tile: TileColors {
|
||||||
|
playing_active_bg: rotate_color(theme.tile.playing_active_bg, degrees),
|
||||||
|
playing_active_fg: rotate_color(theme.tile.playing_active_fg, degrees),
|
||||||
|
playing_inactive_bg: rotate_color(theme.tile.playing_inactive_bg, degrees),
|
||||||
|
playing_inactive_fg: rotate_color(theme.tile.playing_inactive_fg, degrees),
|
||||||
|
active_bg: rotate_color(theme.tile.active_bg, degrees),
|
||||||
|
active_fg: rotate_color(theme.tile.active_fg, degrees),
|
||||||
|
inactive_bg: rotate_color(theme.tile.inactive_bg, degrees),
|
||||||
|
inactive_fg: rotate_color(theme.tile.inactive_fg, degrees),
|
||||||
|
active_selected_bg: rotate_color(theme.tile.active_selected_bg, degrees),
|
||||||
|
active_in_range_bg: rotate_color(theme.tile.active_in_range_bg, degrees),
|
||||||
|
link_bright: [
|
||||||
|
rotate_tuple(theme.tile.link_bright[0], degrees),
|
||||||
|
rotate_tuple(theme.tile.link_bright[1], degrees),
|
||||||
|
rotate_tuple(theme.tile.link_bright[2], degrees),
|
||||||
|
rotate_tuple(theme.tile.link_bright[3], degrees),
|
||||||
|
rotate_tuple(theme.tile.link_bright[4], degrees),
|
||||||
|
],
|
||||||
|
link_dim: [
|
||||||
|
rotate_tuple(theme.tile.link_dim[0], degrees),
|
||||||
|
rotate_tuple(theme.tile.link_dim[1], degrees),
|
||||||
|
rotate_tuple(theme.tile.link_dim[2], degrees),
|
||||||
|
rotate_tuple(theme.tile.link_dim[3], degrees),
|
||||||
|
rotate_tuple(theme.tile.link_dim[4], degrees),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
header: HeaderColors {
|
||||||
|
tempo_bg: rotate_color(theme.header.tempo_bg, degrees),
|
||||||
|
tempo_fg: rotate_color(theme.header.tempo_fg, degrees),
|
||||||
|
bank_bg: rotate_color(theme.header.bank_bg, degrees),
|
||||||
|
bank_fg: rotate_color(theme.header.bank_fg, degrees),
|
||||||
|
pattern_bg: rotate_color(theme.header.pattern_bg, degrees),
|
||||||
|
pattern_fg: rotate_color(theme.header.pattern_fg, degrees),
|
||||||
|
stats_bg: rotate_color(theme.header.stats_bg, degrees),
|
||||||
|
stats_fg: rotate_color(theme.header.stats_fg, degrees),
|
||||||
|
},
|
||||||
|
modal: ModalColors {
|
||||||
|
border: rotate_color(theme.modal.border, degrees),
|
||||||
|
border_accent: rotate_color(theme.modal.border_accent, degrees),
|
||||||
|
border_warn: rotate_color(theme.modal.border_warn, degrees),
|
||||||
|
border_dim: rotate_color(theme.modal.border_dim, degrees),
|
||||||
|
confirm: rotate_color(theme.modal.confirm, degrees),
|
||||||
|
rename: rotate_color(theme.modal.rename, degrees),
|
||||||
|
input: rotate_color(theme.modal.input, degrees),
|
||||||
|
editor: rotate_color(theme.modal.editor, degrees),
|
||||||
|
preview: rotate_color(theme.modal.preview, degrees),
|
||||||
|
},
|
||||||
|
flash: FlashColors {
|
||||||
|
error_bg: rotate_color(theme.flash.error_bg, degrees),
|
||||||
|
error_fg: rotate_color(theme.flash.error_fg, degrees),
|
||||||
|
success_bg: rotate_color(theme.flash.success_bg, degrees),
|
||||||
|
success_fg: rotate_color(theme.flash.success_fg, degrees),
|
||||||
|
info_bg: rotate_color(theme.flash.info_bg, degrees),
|
||||||
|
info_fg: rotate_color(theme.flash.info_fg, degrees),
|
||||||
|
event_rgb: rotate_tuple(theme.flash.event_rgb, degrees),
|
||||||
|
},
|
||||||
|
list: ListColors {
|
||||||
|
playing_bg: rotate_color(theme.list.playing_bg, degrees),
|
||||||
|
playing_fg: rotate_color(theme.list.playing_fg, degrees),
|
||||||
|
staged_play_bg: rotate_color(theme.list.staged_play_bg, degrees),
|
||||||
|
staged_play_fg: rotate_color(theme.list.staged_play_fg, degrees),
|
||||||
|
staged_stop_bg: rotate_color(theme.list.staged_stop_bg, degrees),
|
||||||
|
staged_stop_fg: rotate_color(theme.list.staged_stop_fg, degrees),
|
||||||
|
edit_bg: rotate_color(theme.list.edit_bg, degrees),
|
||||||
|
edit_fg: rotate_color(theme.list.edit_fg, degrees),
|
||||||
|
hover_bg: rotate_color(theme.list.hover_bg, degrees),
|
||||||
|
hover_fg: rotate_color(theme.list.hover_fg, degrees),
|
||||||
|
},
|
||||||
|
link_status: LinkStatusColors {
|
||||||
|
disabled: rotate_color(theme.link_status.disabled, degrees),
|
||||||
|
connected: rotate_color(theme.link_status.connected, degrees),
|
||||||
|
listening: rotate_color(theme.link_status.listening, degrees),
|
||||||
|
},
|
||||||
|
syntax: SyntaxColors {
|
||||||
|
gap_bg: rotate_color(theme.syntax.gap_bg, degrees),
|
||||||
|
executed_bg: rotate_color(theme.syntax.executed_bg, degrees),
|
||||||
|
selected_bg: rotate_color(theme.syntax.selected_bg, degrees),
|
||||||
|
emit: rotate_color_pair(theme.syntax.emit, degrees),
|
||||||
|
number: rotate_color_pair(theme.syntax.number, degrees),
|
||||||
|
string: rotate_color_pair(theme.syntax.string, degrees),
|
||||||
|
comment: rotate_color_pair(theme.syntax.comment, degrees),
|
||||||
|
keyword: rotate_color_pair(theme.syntax.keyword, degrees),
|
||||||
|
stack_op: rotate_color_pair(theme.syntax.stack_op, degrees),
|
||||||
|
operator: rotate_color_pair(theme.syntax.operator, degrees),
|
||||||
|
sound: rotate_color_pair(theme.syntax.sound, degrees),
|
||||||
|
param: rotate_color_pair(theme.syntax.param, degrees),
|
||||||
|
context: rotate_color_pair(theme.syntax.context, degrees),
|
||||||
|
note: rotate_color_pair(theme.syntax.note, degrees),
|
||||||
|
interval: rotate_color_pair(theme.syntax.interval, degrees),
|
||||||
|
variable: rotate_color_pair(theme.syntax.variable, degrees),
|
||||||
|
vary: rotate_color_pair(theme.syntax.vary, degrees),
|
||||||
|
generator: rotate_color_pair(theme.syntax.generator, degrees),
|
||||||
|
default: rotate_color_pair(theme.syntax.default, degrees),
|
||||||
|
},
|
||||||
|
table: TableColors {
|
||||||
|
row_even: rotate_color(theme.table.row_even, degrees),
|
||||||
|
row_odd: rotate_color(theme.table.row_odd, degrees),
|
||||||
|
},
|
||||||
|
values: ValuesColors {
|
||||||
|
tempo: rotate_color(theme.values.tempo, degrees),
|
||||||
|
value: rotate_color(theme.values.value, degrees),
|
||||||
|
},
|
||||||
|
hint: HintColors {
|
||||||
|
key: rotate_color(theme.hint.key, degrees),
|
||||||
|
text: rotate_color(theme.hint.text, degrees),
|
||||||
|
},
|
||||||
|
view_badge: ViewBadgeColors {
|
||||||
|
bg: rotate_color(theme.view_badge.bg, degrees),
|
||||||
|
fg: rotate_color(theme.view_badge.fg, degrees),
|
||||||
|
},
|
||||||
|
nav: NavColors {
|
||||||
|
selected_bg: rotate_color(theme.nav.selected_bg, degrees),
|
||||||
|
selected_fg: rotate_color(theme.nav.selected_fg, degrees),
|
||||||
|
unselected_bg: rotate_color(theme.nav.unselected_bg, degrees),
|
||||||
|
unselected_fg: rotate_color(theme.nav.unselected_fg, degrees),
|
||||||
|
},
|
||||||
|
editor_widget: EditorWidgetColors {
|
||||||
|
cursor_bg: rotate_color(theme.editor_widget.cursor_bg, degrees),
|
||||||
|
cursor_fg: rotate_color(theme.editor_widget.cursor_fg, degrees),
|
||||||
|
selection_bg: rotate_color(theme.editor_widget.selection_bg, degrees),
|
||||||
|
completion_bg: rotate_color(theme.editor_widget.completion_bg, degrees),
|
||||||
|
completion_fg: rotate_color(theme.editor_widget.completion_fg, degrees),
|
||||||
|
completion_selected: rotate_color(theme.editor_widget.completion_selected, degrees),
|
||||||
|
completion_example: rotate_color(theme.editor_widget.completion_example, degrees),
|
||||||
|
},
|
||||||
|
browser: BrowserColors {
|
||||||
|
directory: rotate_color(theme.browser.directory, degrees),
|
||||||
|
project_file: rotate_color(theme.browser.project_file, degrees),
|
||||||
|
selected: rotate_color(theme.browser.selected, degrees),
|
||||||
|
file: rotate_color(theme.browser.file, degrees),
|
||||||
|
focused_border: rotate_color(theme.browser.focused_border, degrees),
|
||||||
|
unfocused_border: rotate_color(theme.browser.unfocused_border, degrees),
|
||||||
|
root: rotate_color(theme.browser.root, degrees),
|
||||||
|
file_icon: rotate_color(theme.browser.file_icon, degrees),
|
||||||
|
folder_icon: rotate_color(theme.browser.folder_icon, degrees),
|
||||||
|
empty_text: rotate_color(theme.browser.empty_text, degrees),
|
||||||
|
},
|
||||||
|
input: InputColors {
|
||||||
|
text: rotate_color(theme.input.text, degrees),
|
||||||
|
cursor: rotate_color(theme.input.cursor, degrees),
|
||||||
|
hint: rotate_color(theme.input.hint, degrees),
|
||||||
|
},
|
||||||
|
search: SearchColors {
|
||||||
|
active: rotate_color(theme.search.active, degrees),
|
||||||
|
inactive: rotate_color(theme.search.inactive, degrees),
|
||||||
|
match_bg: rotate_color(theme.search.match_bg, degrees),
|
||||||
|
match_fg: rotate_color(theme.search.match_fg, degrees),
|
||||||
|
},
|
||||||
|
markdown: MarkdownColors {
|
||||||
|
h1: rotate_color(theme.markdown.h1, degrees),
|
||||||
|
h2: rotate_color(theme.markdown.h2, degrees),
|
||||||
|
h3: rotate_color(theme.markdown.h3, degrees),
|
||||||
|
code: rotate_color(theme.markdown.code, degrees),
|
||||||
|
code_border: rotate_color(theme.markdown.code_border, degrees),
|
||||||
|
link: rotate_color(theme.markdown.link, degrees),
|
||||||
|
link_url: rotate_color(theme.markdown.link_url, degrees),
|
||||||
|
quote: rotate_color(theme.markdown.quote, degrees),
|
||||||
|
text: rotate_color(theme.markdown.text, degrees),
|
||||||
|
list: rotate_color(theme.markdown.list, degrees),
|
||||||
|
},
|
||||||
|
engine: EngineColors {
|
||||||
|
header: rotate_color(theme.engine.header, degrees),
|
||||||
|
header_focused: rotate_color(theme.engine.header_focused, degrees),
|
||||||
|
divider: rotate_color(theme.engine.divider, degrees),
|
||||||
|
scroll_indicator: rotate_color(theme.engine.scroll_indicator, degrees),
|
||||||
|
label: rotate_color(theme.engine.label, degrees),
|
||||||
|
label_focused: rotate_color(theme.engine.label_focused, degrees),
|
||||||
|
label_dim: rotate_color(theme.engine.label_dim, degrees),
|
||||||
|
value: rotate_color(theme.engine.value, degrees),
|
||||||
|
focused: rotate_color(theme.engine.focused, degrees),
|
||||||
|
normal: rotate_color(theme.engine.normal, degrees),
|
||||||
|
dim: rotate_color(theme.engine.dim, degrees),
|
||||||
|
path: rotate_color(theme.engine.path, degrees),
|
||||||
|
border_magenta: rotate_color(theme.engine.border_magenta, degrees),
|
||||||
|
border_green: rotate_color(theme.engine.border_green, degrees),
|
||||||
|
border_cyan: rotate_color(theme.engine.border_cyan, degrees),
|
||||||
|
separator: rotate_color(theme.engine.separator, degrees),
|
||||||
|
hint_active: rotate_color(theme.engine.hint_active, degrees),
|
||||||
|
hint_inactive: rotate_color(theme.engine.hint_inactive, degrees),
|
||||||
|
},
|
||||||
|
dict: DictColors {
|
||||||
|
word_name: rotate_color(theme.dict.word_name, degrees),
|
||||||
|
word_bg: rotate_color(theme.dict.word_bg, degrees),
|
||||||
|
alias: rotate_color(theme.dict.alias, degrees),
|
||||||
|
stack_sig: rotate_color(theme.dict.stack_sig, degrees),
|
||||||
|
description: rotate_color(theme.dict.description, degrees),
|
||||||
|
example: rotate_color(theme.dict.example, degrees),
|
||||||
|
category_focused: rotate_color(theme.dict.category_focused, degrees),
|
||||||
|
category_selected: rotate_color(theme.dict.category_selected, degrees),
|
||||||
|
category_normal: rotate_color(theme.dict.category_normal, degrees),
|
||||||
|
category_dimmed: rotate_color(theme.dict.category_dimmed, degrees),
|
||||||
|
border_focused: rotate_color(theme.dict.border_focused, degrees),
|
||||||
|
border_normal: rotate_color(theme.dict.border_normal, degrees),
|
||||||
|
header_desc: rotate_color(theme.dict.header_desc, degrees),
|
||||||
|
},
|
||||||
|
title: TitleColors {
|
||||||
|
big_title: rotate_color(theme.title.big_title, degrees),
|
||||||
|
author: rotate_color(theme.title.author, degrees),
|
||||||
|
link: rotate_color(theme.title.link, degrees),
|
||||||
|
license: rotate_color(theme.title.license, degrees),
|
||||||
|
prompt: rotate_color(theme.title.prompt, degrees),
|
||||||
|
subtitle: rotate_color(theme.title.subtitle, degrees),
|
||||||
|
},
|
||||||
|
meter: MeterColors {
|
||||||
|
low: rotate_color(theme.meter.low, degrees),
|
||||||
|
mid: rotate_color(theme.meter.mid, degrees),
|
||||||
|
high: rotate_color(theme.meter.high, degrees),
|
||||||
|
low_rgb: rotate_tuple(theme.meter.low_rgb, degrees),
|
||||||
|
mid_rgb: rotate_tuple(theme.meter.mid_rgb, degrees),
|
||||||
|
high_rgb: rotate_tuple(theme.meter.high_rgb, degrees),
|
||||||
|
},
|
||||||
|
sparkle: SparkleColors {
|
||||||
|
colors: [
|
||||||
|
rotate_tuple(theme.sparkle.colors[0], degrees),
|
||||||
|
rotate_tuple(theme.sparkle.colors[1], degrees),
|
||||||
|
rotate_tuple(theme.sparkle.colors[2], degrees),
|
||||||
|
rotate_tuple(theme.sparkle.colors[3], degrees),
|
||||||
|
rotate_tuple(theme.sparkle.colors[4], degrees),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
confirm: ConfirmColors {
|
||||||
|
border: rotate_color(theme.confirm.border, degrees),
|
||||||
|
button_selected_bg: rotate_color(theme.confirm.button_selected_bg, degrees),
|
||||||
|
button_selected_fg: rotate_color(theme.confirm.button_selected_fg, degrees),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -193,21 +193,42 @@ You can also use quotations if you need to execute code:
|
|||||||
|
|
||||||
When the selected value is a quotation, it gets executed. When it is a plain value, it gets pushed onto the stack.
|
When the selected value is a quotation, it gets executed. When it is a plain value, it gets pushed onto the stack.
|
||||||
|
|
||||||
Three cycling words exist:
|
Two cycling words exist:
|
||||||
|
|
||||||
- `cycle` - selects based on `runs` (how many times this step has played)
|
- `cycle` - selects based on `runs` (how many times this step has played)
|
||||||
- `pcycle` - selects based on `iter` (how many times the pattern has looped)
|
- `pcycle` - selects based on `iter` (how many times the pattern has looped)
|
||||||
- `tcycle` - creates a cycle list that resolves at emit time
|
|
||||||
|
|
||||||
The difference between `cycle` and `pcycle` matters when patterns have different lengths. `cycle` counts per-step, `pcycle` counts per-pattern.
|
The difference between `cycle` and `pcycle` matters when patterns have different lengths. `cycle` counts per-step, `pcycle` counts per-pattern.
|
||||||
|
|
||||||
`tcycle` is special. It does not select immediately. Instead it creates a value that cycles when emitted:
|
## Polyphonic Parameters
|
||||||
|
|
||||||
|
Parameter words like `note`, `freq`, and `gain` consume the entire stack. If you push multiple values before a param word, you get polyphony:
|
||||||
|
|
||||||
```forth
|
```forth
|
||||||
0.3 0.5 0.7 3 tcycle gain
|
60 64 67 note sine s . ;; emits 3 voices with notes 60, 64, 67
|
||||||
```
|
```
|
||||||
|
|
||||||
If you emit multiple times in one step (using `at`), each emit gets the next value from the cycle.
|
This works for any param and for the sound word itself:
|
||||||
|
|
||||||
|
```forth
|
||||||
|
440 880 freq sine tri s . ;; 2 voices: sine at 440, tri at 880
|
||||||
|
```
|
||||||
|
|
||||||
|
When params have different lengths, shorter lists cycle:
|
||||||
|
|
||||||
|
```forth
|
||||||
|
60 64 67 note ;; 3 notes
|
||||||
|
0.5 1.0 gain ;; 2 gains (cycles: 0.5, 1.0, 0.5)
|
||||||
|
sine s . ;; emits 3 voices
|
||||||
|
```
|
||||||
|
|
||||||
|
Polyphony multiplies with `at` deltas:
|
||||||
|
|
||||||
|
```forth
|
||||||
|
0 0.5 at ;; 2 time points
|
||||||
|
60 64 note ;; 2 notes
|
||||||
|
sine s . ;; emits 4 voices (2 notes × 2 times)
|
||||||
|
```
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ allow-branch = ["main"]
|
|||||||
sign-commit = false
|
sign-commit = false
|
||||||
sign-tag = false
|
sign-tag = false
|
||||||
push = true
|
push = true
|
||||||
|
push-remote = "github"
|
||||||
publish = false
|
publish = false
|
||||||
tag-name = "v{{version}}"
|
tag-name = "v{{version}}"
|
||||||
|
|||||||
113
src/app.rs
113
src/app.rs
@@ -110,6 +110,8 @@ impl App {
|
|||||||
show_completion: self.ui.show_completion,
|
show_completion: self.ui.show_completion,
|
||||||
flash_brightness: self.ui.flash_brightness,
|
flash_brightness: self.ui.flash_brightness,
|
||||||
color_scheme: self.ui.color_scheme,
|
color_scheme: self.ui.color_scheme,
|
||||||
|
layout: self.audio.config.layout,
|
||||||
|
hue_rotation: self.ui.hue_rotation,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
link: crate::settings::LinkSettings {
|
link: crate::settings::LinkSettings {
|
||||||
@@ -527,10 +529,15 @@ impl App {
|
|||||||
self.load_step_to_editor();
|
self.load_step_to_editor();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&mut self, path: PathBuf, link: &LinkState) {
|
pub fn save(&mut self, path: PathBuf, link: &LinkState, snapshot: &SequencerSnapshot) {
|
||||||
self.save_editor_to_step();
|
self.save_editor_to_step();
|
||||||
self.project_state.project.sample_paths = self.audio.config.sample_paths.clone();
|
self.project_state.project.sample_paths = self.audio.config.sample_paths.clone();
|
||||||
self.project_state.project.tempo = link.tempo();
|
self.project_state.project.tempo = link.tempo();
|
||||||
|
self.project_state.project.playing_patterns = snapshot
|
||||||
|
.active_patterns
|
||||||
|
.iter()
|
||||||
|
.map(|p| (p.bank, p.pattern))
|
||||||
|
.collect();
|
||||||
match model::save(&self.project_state.project, &path) {
|
match model::save(&self.project_state.project, &path) {
|
||||||
Ok(final_path) => {
|
Ok(final_path) => {
|
||||||
self.ui
|
self.ui
|
||||||
@@ -547,12 +554,27 @@ impl App {
|
|||||||
match model::load(&path) {
|
match model::load(&path) {
|
||||||
Ok(project) => {
|
Ok(project) => {
|
||||||
let tempo = project.tempo;
|
let tempo = project.tempo;
|
||||||
|
let playing = project.playing_patterns.clone();
|
||||||
|
|
||||||
self.project_state.project = project;
|
self.project_state.project = project;
|
||||||
self.editor_ctx.step = 0;
|
self.editor_ctx.step = 0;
|
||||||
self.load_step_to_editor();
|
self.load_step_to_editor();
|
||||||
self.compile_all_steps(link);
|
self.compile_all_steps(link);
|
||||||
self.mark_all_patterns_dirty();
|
self.mark_all_patterns_dirty();
|
||||||
link.set_tempo(tempo);
|
link.set_tempo(tempo);
|
||||||
|
|
||||||
|
self.playback.clear_queues();
|
||||||
|
self.variables.lock().unwrap().clear();
|
||||||
|
self.dict.lock().unwrap().clear();
|
||||||
|
|
||||||
|
for (bank, pattern) in playing {
|
||||||
|
self.playback.queued_changes.push(StagedChange {
|
||||||
|
change: PatternChange::Start { bank, pattern },
|
||||||
|
quantization: crate::model::LaunchQuantization::Immediate,
|
||||||
|
sync_mode: crate::model::SyncMode::Reset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
self.ui.set_status(format!("Loaded: {}", path.display()));
|
self.ui.set_status(format!("Loaded: {}", path.display()));
|
||||||
self.project_state.file_path = Some(path);
|
self.project_state.file_path = Some(path);
|
||||||
}
|
}
|
||||||
@@ -1103,7 +1125,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.project_state.mark_dirty(bank, pattern);
|
self.project_state.mark_dirty(bank, pattern);
|
||||||
}
|
}
|
||||||
AppCommand::Save(path) => self.save(path, link),
|
AppCommand::Save(path) => self.save(path, link, snapshot),
|
||||||
AppCommand::Load(path) => self.load(path, link),
|
AppCommand::Load(path) => self.load(path, link),
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
@@ -1290,7 +1312,15 @@ impl App {
|
|||||||
}
|
}
|
||||||
AppCommand::SetColorScheme(scheme) => {
|
AppCommand::SetColorScheme(scheme) => {
|
||||||
self.ui.color_scheme = scheme;
|
self.ui.color_scheme = scheme;
|
||||||
crate::theme::set(scheme.to_theme());
|
let base_theme = scheme.to_theme();
|
||||||
|
let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, self.ui.hue_rotation);
|
||||||
|
crate::theme::set(rotated);
|
||||||
|
}
|
||||||
|
AppCommand::SetHueRotation(degrees) => {
|
||||||
|
self.ui.hue_rotation = degrees;
|
||||||
|
let base_theme = self.ui.color_scheme.to_theme();
|
||||||
|
let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, degrees);
|
||||||
|
crate::theme::set(rotated);
|
||||||
}
|
}
|
||||||
AppCommand::ToggleRuntimeHighlight => {
|
AppCommand::ToggleRuntimeHighlight => {
|
||||||
self.ui.runtime_highlight = !self.ui.runtime_highlight;
|
self.ui.runtime_highlight = !self.ui.runtime_highlight;
|
||||||
@@ -1408,6 +1438,65 @@ impl App {
|
|||||||
AppCommand::ResetPeakVoices => {
|
AppCommand::ResetPeakVoices => {
|
||||||
self.metrics.peak_voices = 0;
|
self.metrics.peak_voices = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Euclidean distribution
|
||||||
|
AppCommand::ApplyEuclideanDistribution {
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
source_step,
|
||||||
|
pulses,
|
||||||
|
steps,
|
||||||
|
rotation,
|
||||||
|
} => {
|
||||||
|
let pat_len = self.project_state.project.pattern_at(bank, pattern).length;
|
||||||
|
let rhythm = euclidean_rhythm(pulses, steps, rotation);
|
||||||
|
|
||||||
|
let mut created_count = 0;
|
||||||
|
for (i, &is_hit) in rhythm.iter().enumerate() {
|
||||||
|
if !is_hit {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = (source_step + i) % pat_len;
|
||||||
|
|
||||||
|
if target == source_step {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(step) = self
|
||||||
|
.project_state
|
||||||
|
.project
|
||||||
|
.pattern_at_mut(bank, pattern)
|
||||||
|
.step_mut(target)
|
||||||
|
{
|
||||||
|
step.source = Some(source_step);
|
||||||
|
step.script.clear();
|
||||||
|
step.command = None;
|
||||||
|
step.active = true;
|
||||||
|
}
|
||||||
|
created_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.project_state.mark_dirty(bank, pattern);
|
||||||
|
|
||||||
|
for (i, &is_hit) in rhythm.iter().enumerate() {
|
||||||
|
if !is_hit || i == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let target = (source_step + i) % pat_len;
|
||||||
|
let saved = self.editor_ctx.step;
|
||||||
|
self.editor_ctx.step = target;
|
||||||
|
self.compile_current_step(link);
|
||||||
|
self.editor_ctx.step = saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.load_step_to_editor();
|
||||||
|
self.ui.flash(
|
||||||
|
&format!("Created {} linked steps (E({pulses},{steps}))", created_count),
|
||||||
|
200,
|
||||||
|
FlashKind::Success,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1460,3 +1549,21 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn euclidean_rhythm(pulses: usize, steps: usize, rotation: usize) -> Vec<bool> {
|
||||||
|
if pulses == 0 || steps == 0 || pulses > steps {
|
||||||
|
return vec![false; steps];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pattern = vec![false; steps];
|
||||||
|
for i in 0..pulses {
|
||||||
|
let pos = (i * steps) / pulses;
|
||||||
|
pattern[pos] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if rotation > 0 {
|
||||||
|
pattern.rotate_left(rotation % steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern
|
||||||
|
}
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ pub enum AppCommand {
|
|||||||
HideTitle,
|
HideTitle,
|
||||||
ToggleEditorStack,
|
ToggleEditorStack,
|
||||||
SetColorScheme(ColorScheme),
|
SetColorScheme(ColorScheme),
|
||||||
|
SetHueRotation(f32),
|
||||||
ToggleRuntimeHighlight,
|
ToggleRuntimeHighlight,
|
||||||
ToggleCompletion,
|
ToggleCompletion,
|
||||||
AdjustFlashBrightness(f32),
|
AdjustFlashBrightness(f32),
|
||||||
@@ -207,4 +208,13 @@ pub enum AppCommand {
|
|||||||
// Metrics
|
// Metrics
|
||||||
ResetPeakVoices,
|
ResetPeakVoices,
|
||||||
|
|
||||||
|
// Euclidean distribution
|
||||||
|
ApplyEuclideanDistribution {
|
||||||
|
bank: usize,
|
||||||
|
pattern: usize,
|
||||||
|
source_step: usize,
|
||||||
|
pulses: usize,
|
||||||
|
steps: usize,
|
||||||
|
rotation: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -450,6 +450,10 @@ impl RunsCounter {
|
|||||||
*count += 1;
|
*count += 1;
|
||||||
current
|
current
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clear_pattern(&mut self, bank: usize, pattern: usize) {
|
||||||
|
self.counts.retain(|&(b, p, _), _| b != bank || p != pattern);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct TickInput {
|
pub(crate) struct TickInput {
|
||||||
@@ -716,6 +720,7 @@ impl SequencerState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
self.runs_counter.clear_pattern(pending.id.bank, pending.id.pattern);
|
||||||
self.audio_state.active_patterns.insert(
|
self.audio_state.active_patterns.insert(
|
||||||
pending.id,
|
pending.id,
|
||||||
ActivePattern {
|
ActivePattern {
|
||||||
|
|||||||
105
src/input.rs
105
src/input.rs
@@ -11,8 +11,8 @@ use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot};
|
|||||||
use crate::model::PatternSpeed;
|
use crate::model::PatternSpeed;
|
||||||
use crate::page::Page;
|
use crate::page::Page;
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
DeviceKind, EngineSection, Modal, OptionsFocus, PanelFocus, PatternField, PatternPropsField,
|
CyclicEnum, DeviceKind, EngineSection, EuclideanField, Modal, OptionsFocus, PanelFocus,
|
||||||
SampleBrowserState, SettingKind, SidePanel,
|
PatternField, PatternPropsField, SampleBrowserState, SettingKind, SidePanel,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub enum InputResult {
|
pub enum InputResult {
|
||||||
@@ -258,6 +258,7 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
match mode {
|
match mode {
|
||||||
FileBrowserMode::Save => ctx.dispatch(AppCommand::Save(path)),
|
FileBrowserMode::Save => ctx.dispatch(AppCommand::Save(path)),
|
||||||
FileBrowserMode::Load => {
|
FileBrowserMode::Load => {
|
||||||
|
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
|
||||||
ctx.dispatch(AppCommand::Load(path));
|
ctx.dispatch(AppCommand::Load(path));
|
||||||
load_project_samples(ctx);
|
load_project_samples(ctx);
|
||||||
}
|
}
|
||||||
@@ -640,6 +641,79 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Modal::EuclideanDistribution {
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
source_step,
|
||||||
|
field,
|
||||||
|
pulses,
|
||||||
|
steps,
|
||||||
|
rotation,
|
||||||
|
} => {
|
||||||
|
let (bank_val, pattern_val, source_step_val) = (*bank, *pattern, *source_step);
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up => *field = field.prev(),
|
||||||
|
KeyCode::Down | KeyCode::Tab => *field = field.next(),
|
||||||
|
KeyCode::Left => {
|
||||||
|
let target = match field {
|
||||||
|
EuclideanField::Pulses => pulses,
|
||||||
|
EuclideanField::Steps => steps,
|
||||||
|
EuclideanField::Rotation => rotation,
|
||||||
|
};
|
||||||
|
if let Ok(val) = target.parse::<usize>() {
|
||||||
|
*target = val.saturating_sub(1).to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
let target = match field {
|
||||||
|
EuclideanField::Pulses => pulses,
|
||||||
|
EuclideanField::Steps => steps,
|
||||||
|
EuclideanField::Rotation => rotation,
|
||||||
|
};
|
||||||
|
if let Ok(val) = target.parse::<usize>() {
|
||||||
|
*target = (val + 1).min(128).to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) if c.is_ascii_digit() => match field {
|
||||||
|
EuclideanField::Pulses => pulses.push(c),
|
||||||
|
EuclideanField::Steps => steps.push(c),
|
||||||
|
EuclideanField::Rotation => rotation.push(c),
|
||||||
|
},
|
||||||
|
KeyCode::Backspace => match field {
|
||||||
|
EuclideanField::Pulses => {
|
||||||
|
pulses.pop();
|
||||||
|
}
|
||||||
|
EuclideanField::Steps => {
|
||||||
|
steps.pop();
|
||||||
|
}
|
||||||
|
EuclideanField::Rotation => {
|
||||||
|
rotation.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let pulses_val: usize = pulses.parse().unwrap_or(0);
|
||||||
|
let steps_val: usize = steps.parse().unwrap_or(0);
|
||||||
|
let rotation_val: usize = rotation.parse().unwrap_or(0);
|
||||||
|
if pulses_val > 0 && steps_val > 0 && pulses_val <= steps_val {
|
||||||
|
ctx.dispatch(AppCommand::ApplyEuclideanDistribution {
|
||||||
|
bank: bank_val,
|
||||||
|
pattern: pattern_val,
|
||||||
|
source_step: source_step_val,
|
||||||
|
pulses: pulses_val,
|
||||||
|
steps: steps_val,
|
||||||
|
rotation: rotation_val,
|
||||||
|
});
|
||||||
|
ctx.dispatch(AppCommand::CloseModal);
|
||||||
|
} else {
|
||||||
|
ctx.dispatch(AppCommand::SetStatus(
|
||||||
|
"Invalid: pulses must be > 0 and <= steps".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
Modal::None => unreachable!(),
|
Modal::None => unreachable!(),
|
||||||
}
|
}
|
||||||
InputResult::Continue
|
InputResult::Continue
|
||||||
@@ -955,9 +1029,31 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
|
|||||||
name: current_name,
|
name: current_name,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('o') => {
|
||||||
|
ctx.app.audio.config.layout = ctx.app.audio.config.layout.next();
|
||||||
|
}
|
||||||
KeyCode::Char('?') => {
|
KeyCode::Char('?') => {
|
||||||
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('e') | KeyCode::Char('E') => {
|
||||||
|
let (bank, pattern, step) = (
|
||||||
|
ctx.app.editor_ctx.bank,
|
||||||
|
ctx.app.editor_ctx.pattern,
|
||||||
|
ctx.app.editor_ctx.step,
|
||||||
|
);
|
||||||
|
let pattern_len = ctx.app.current_edit_pattern().length;
|
||||||
|
let default_steps = pattern_len.min(32);
|
||||||
|
let default_pulses = (default_steps / 2).max(1).min(default_steps);
|
||||||
|
ctx.dispatch(AppCommand::OpenModal(Modal::EuclideanDistribution {
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
source_step: step,
|
||||||
|
field: EuclideanField::Pulses,
|
||||||
|
pulses: default_pulses.to_string(),
|
||||||
|
steps: default_steps.to_string(),
|
||||||
|
rotation: "0".to_string(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
InputResult::Continue
|
InputResult::Continue
|
||||||
@@ -1288,6 +1384,11 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
};
|
};
|
||||||
ctx.dispatch(AppCommand::SetColorScheme(new_scheme));
|
ctx.dispatch(AppCommand::SetColorScheme(new_scheme));
|
||||||
}
|
}
|
||||||
|
OptionsFocus::HueRotation => {
|
||||||
|
let delta = if key.code == KeyCode::Left { -1.0 } else { 1.0 };
|
||||||
|
let new_rotation = (ctx.app.ui.hue_rotation + delta).rem_euclid(360.0);
|
||||||
|
ctx.dispatch(AppCommand::SetHueRotation(new_rotation));
|
||||||
|
}
|
||||||
OptionsFocus::RefreshRate => ctx.dispatch(AppCommand::ToggleRefreshRate),
|
OptionsFocus::RefreshRate => ctx.dispatch(AppCommand::ToggleRefreshRate),
|
||||||
OptionsFocus::RuntimeHighlight => {
|
OptionsFocus::RuntimeHighlight => {
|
||||||
ctx.dispatch(AppCommand::ToggleRuntimeHighlight);
|
ctx.dispatch(AppCommand::ToggleRuntimeHighlight);
|
||||||
|
|||||||
@@ -100,7 +100,11 @@ fn main() -> io::Result<()> {
|
|||||||
app.ui.show_completion = settings.display.show_completion;
|
app.ui.show_completion = settings.display.show_completion;
|
||||||
app.ui.flash_brightness = settings.display.flash_brightness;
|
app.ui.flash_brightness = settings.display.flash_brightness;
|
||||||
app.ui.color_scheme = settings.display.color_scheme;
|
app.ui.color_scheme = settings.display.color_scheme;
|
||||||
theme::set(settings.display.color_scheme.to_theme());
|
app.ui.hue_rotation = settings.display.hue_rotation;
|
||||||
|
app.audio.config.layout = settings.display.layout;
|
||||||
|
let base_theme = settings.display.color_scheme.to_theme();
|
||||||
|
let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, settings.display.hue_rotation);
|
||||||
|
theme::set(rotated);
|
||||||
|
|
||||||
// Load MIDI settings
|
// Load MIDI settings
|
||||||
let outputs = midi::list_midi_outputs();
|
let outputs = midi::list_midi_outputs();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::state::ColorScheme;
|
use crate::state::{ColorScheme, MainLayout};
|
||||||
|
|
||||||
const APP_NAME: &str = "cagire";
|
const APP_NAME: &str = "cagire";
|
||||||
|
|
||||||
@@ -50,6 +50,10 @@ pub struct DisplaySettings {
|
|||||||
pub font: String,
|
pub font: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub color_scheme: ColorScheme,
|
pub color_scheme: ColorScheme,
|
||||||
|
#[serde(default)]
|
||||||
|
pub layout: MainLayout,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hue_rotation: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_font() -> String {
|
fn default_font() -> String {
|
||||||
@@ -91,6 +95,8 @@ impl Default for DisplaySettings {
|
|||||||
flash_brightness: 1.0,
|
flash_brightness: 1.0,
|
||||||
font: default_font(),
|
font: default_font(),
|
||||||
color_scheme: ColorScheme::default(),
|
color_scheme: ColorScheme::default(),
|
||||||
|
layout: MainLayout::default(),
|
||||||
|
hue_rotation: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
use doux::audio::AudioDeviceInfo;
|
use doux::audio::AudioDeviceInfo;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use super::CyclicEnum;
|
use super::CyclicEnum;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub enum MainLayout {
|
||||||
|
#[default]
|
||||||
|
Top,
|
||||||
|
Bottom,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CyclicEnum for MainLayout {
|
||||||
|
const VARIANTS: &'static [Self] = &[Self::Top, Self::Bottom, Self::Left, Self::Right];
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum RefreshRate {
|
pub enum RefreshRate {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -62,6 +76,7 @@ pub struct AudioConfig {
|
|||||||
pub show_scope: bool,
|
pub show_scope: bool,
|
||||||
pub show_spectrum: bool,
|
pub show_spectrum: bool,
|
||||||
pub lookahead_ms: u32,
|
pub lookahead_ms: u32,
|
||||||
|
pub layout: MainLayout,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AudioConfig {
|
impl Default for AudioConfig {
|
||||||
@@ -79,6 +94,7 @@ impl Default for AudioConfig {
|
|||||||
show_scope: true,
|
show_scope: true,
|
||||||
show_spectrum: true,
|
show_spectrum: true,
|
||||||
lookahead_ms: 15,
|
lookahead_ms: 15,
|
||||||
|
layout: MainLayout::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,32 @@ impl PatternPropsField {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum EuclideanField {
|
||||||
|
#[default]
|
||||||
|
Pulses,
|
||||||
|
Steps,
|
||||||
|
Rotation,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EuclideanField {
|
||||||
|
pub fn next(&self) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::Pulses => Self::Steps,
|
||||||
|
Self::Steps => Self::Rotation,
|
||||||
|
Self::Rotation => Self::Rotation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev(&self) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::Pulses => Self::Pulses,
|
||||||
|
Self::Steps => Self::Pulses,
|
||||||
|
Self::Rotation => Self::Steps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct EditorContext {
|
pub struct EditorContext {
|
||||||
pub bank: usize,
|
pub bank: usize,
|
||||||
pub pattern: usize,
|
pub pattern: usize,
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ pub mod project;
|
|||||||
pub mod sample_browser;
|
pub mod sample_browser;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|
||||||
pub use audio::{AudioSettings, DeviceKind, EngineSection, Metrics, SettingKind};
|
pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, SettingKind};
|
||||||
pub use color_scheme::ColorScheme;
|
pub use color_scheme::ColorScheme;
|
||||||
pub use editor::{
|
pub use editor::{
|
||||||
CopiedStepData, CopiedSteps, EditorContext, PatternField, PatternPropsField, StackCache,
|
CopiedStepData, CopiedSteps, EditorContext, EuclideanField, PatternField, PatternPropsField,
|
||||||
|
StackCache,
|
||||||
};
|
};
|
||||||
pub use live_keys::LiveKeyState;
|
pub use live_keys::LiveKeyState;
|
||||||
pub use modal::Modal;
|
pub use modal::Modal;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
|
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
|
||||||
use crate::state::editor::{PatternField, PatternPropsField};
|
use crate::state::editor::{EuclideanField, PatternField, PatternPropsField};
|
||||||
use crate::state::file_browser::FileBrowserState;
|
use crate::state::file_browser::FileBrowserState;
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq)]
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
@@ -66,4 +66,13 @@ pub enum Modal {
|
|||||||
KeybindingsHelp {
|
KeybindingsHelp {
|
||||||
scroll: usize,
|
scroll: usize,
|
||||||
},
|
},
|
||||||
|
EuclideanDistribution {
|
||||||
|
bank: usize,
|
||||||
|
pattern: usize,
|
||||||
|
source_step: usize,
|
||||||
|
field: EuclideanField,
|
||||||
|
pulses: String,
|
||||||
|
steps: String,
|
||||||
|
rotation: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use super::CyclicEnum;
|
|||||||
pub enum OptionsFocus {
|
pub enum OptionsFocus {
|
||||||
#[default]
|
#[default]
|
||||||
ColorScheme,
|
ColorScheme,
|
||||||
|
HueRotation,
|
||||||
RefreshRate,
|
RefreshRate,
|
||||||
RuntimeHighlight,
|
RuntimeHighlight,
|
||||||
ShowScope,
|
ShowScope,
|
||||||
@@ -26,6 +27,7 @@ pub enum OptionsFocus {
|
|||||||
impl CyclicEnum for OptionsFocus {
|
impl CyclicEnum for OptionsFocus {
|
||||||
const VARIANTS: &'static [Self] = &[
|
const VARIANTS: &'static [Self] = &[
|
||||||
Self::ColorScheme,
|
Self::ColorScheme,
|
||||||
|
Self::HueRotation,
|
||||||
Self::RefreshRate,
|
Self::RefreshRate,
|
||||||
Self::RuntimeHighlight,
|
Self::RuntimeHighlight,
|
||||||
Self::ShowScope,
|
Self::ShowScope,
|
||||||
|
|||||||
@@ -28,4 +28,9 @@ impl PlaybackState {
|
|||||||
pub fn toggle(&mut self) {
|
pub fn toggle(&mut self) {
|
||||||
self.playing = !self.playing;
|
self.playing = !self.playing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn clear_queues(&mut self) {
|
||||||
|
self.staged_changes.clear();
|
||||||
|
self.queued_changes.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pub struct UiState {
|
|||||||
pub event_flash: f32,
|
pub event_flash: f32,
|
||||||
pub flash_brightness: f32,
|
pub flash_brightness: f32,
|
||||||
pub color_scheme: ColorScheme,
|
pub color_scheme: ColorScheme,
|
||||||
|
pub hue_rotation: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiState {
|
impl Default for UiState {
|
||||||
@@ -78,6 +79,7 @@ impl Default for UiState {
|
|||||||
event_flash: 0.0,
|
event_flash: 0.0,
|
||||||
flash_brightness: 1.0,
|
flash_brightness: 1.0,
|
||||||
color_scheme: ColorScheme::default(),
|
color_scheme: ColorScheme::default(),
|
||||||
|
hue_rotation: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str
|
|||||||
bindings.push(("f", "Fill", "Toggle fill mode (hold)"));
|
bindings.push(("f", "Fill", "Toggle fill mode (hold)"));
|
||||||
bindings.push(("r", "Rename", "Rename current step"));
|
bindings.push(("r", "Rename", "Rename current step"));
|
||||||
bindings.push(("Ctrl+R", "Run", "Run step script immediately"));
|
bindings.push(("Ctrl+R", "Run", "Run step script immediately"));
|
||||||
|
bindings.push(("e", "Euclidean", "Distribute linked steps using Euclidean rhythm"));
|
||||||
}
|
}
|
||||||
Page::Patterns => {
|
Page::Patterns => {
|
||||||
bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns"));
|
bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns"));
|
||||||
|
|||||||
@@ -1,65 +1,117 @@
|
|||||||
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::widgets::Paragraph;
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::engine::SequencerSnapshot;
|
use crate::engine::SequencerSnapshot;
|
||||||
|
use crate::state::MainLayout;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::widgets::{Orientation, Scope, Spectrum, VuMeter};
|
use crate::widgets::{ActivePatterns, Orientation, Scope, Spectrum, VuMeter};
|
||||||
|
|
||||||
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||||
let [left_area, _spacer, vu_area] = Layout::horizontal([
|
let [patterns_area, _, main_area, _, vu_area] = Layout::horizontal([
|
||||||
|
Constraint::Length(13),
|
||||||
|
Constraint::Length(2),
|
||||||
Constraint::Fill(1),
|
Constraint::Fill(1),
|
||||||
Constraint::Length(2),
|
Constraint::Length(2),
|
||||||
Constraint::Length(8),
|
Constraint::Length(10),
|
||||||
])
|
])
|
||||||
.areas(area);
|
.areas(area);
|
||||||
|
|
||||||
let show_scope = app.audio.config.show_scope;
|
let show_scope = app.audio.config.show_scope;
|
||||||
let show_spectrum = app.audio.config.show_spectrum;
|
let show_spectrum = app.audio.config.show_spectrum;
|
||||||
let viz_height = if show_scope || show_spectrum { 14 } else { 0 };
|
let has_viz = show_scope || show_spectrum;
|
||||||
|
let layout = app.audio.config.layout;
|
||||||
|
|
||||||
let [viz_area, sequencer_area] = Layout::vertical([
|
let (viz_area, sequencer_area) = match layout {
|
||||||
Constraint::Length(viz_height),
|
MainLayout::Top => {
|
||||||
Constraint::Fill(1),
|
let viz_height = if has_viz { 16 } else { 0 };
|
||||||
])
|
let [viz, seq] = Layout::vertical([
|
||||||
.areas(left_area);
|
Constraint::Length(viz_height),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.areas(main_area);
|
||||||
|
(viz, seq)
|
||||||
|
}
|
||||||
|
MainLayout::Bottom => {
|
||||||
|
let viz_height = if has_viz { 16 } else { 0 };
|
||||||
|
let [seq, viz] = Layout::vertical([
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(viz_height),
|
||||||
|
])
|
||||||
|
.areas(main_area);
|
||||||
|
(viz, seq)
|
||||||
|
}
|
||||||
|
MainLayout::Left => {
|
||||||
|
let viz_width = if has_viz { 33 } else { 0 };
|
||||||
|
let [viz, _spacer, seq] = Layout::horizontal([
|
||||||
|
Constraint::Percentage(viz_width),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.areas(main_area);
|
||||||
|
(viz, seq)
|
||||||
|
}
|
||||||
|
MainLayout::Right => {
|
||||||
|
let viz_width = if has_viz { 33 } else { 0 };
|
||||||
|
let [seq, _spacer, viz] = Layout::horizontal([
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Percentage(viz_width),
|
||||||
|
])
|
||||||
|
.areas(main_area);
|
||||||
|
(viz, seq)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if show_scope && show_spectrum {
|
if has_viz {
|
||||||
let [scope_area, _, spectrum_area] = Layout::horizontal([
|
render_viz_area(frame, app, viz_area, layout, show_scope, show_spectrum);
|
||||||
Constraint::Percentage(50),
|
|
||||||
Constraint::Length(2),
|
|
||||||
Constraint::Percentage(50),
|
|
||||||
])
|
|
||||||
.areas(viz_area);
|
|
||||||
render_scope(frame, app, scope_area);
|
|
||||||
render_spectrum(frame, app, spectrum_area);
|
|
||||||
} else if show_scope {
|
|
||||||
render_scope(frame, app, viz_area);
|
|
||||||
} else if show_spectrum {
|
|
||||||
render_spectrum(frame, app, viz_area);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render_sequencer(frame, app, snapshot, sequencer_area);
|
render_sequencer(frame, app, snapshot, sequencer_area);
|
||||||
|
render_vu_meter(frame, app, vu_area);
|
||||||
|
render_active_patterns(frame, app, snapshot, patterns_area);
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate actual grid height to align VU meter
|
fn render_viz_area(
|
||||||
let pattern = app.current_edit_pattern();
|
frame: &mut Frame,
|
||||||
let page = app.editor_ctx.step / STEPS_PER_PAGE;
|
app: &App,
|
||||||
let page_start = page * STEPS_PER_PAGE;
|
area: Rect,
|
||||||
let steps_on_page = (page_start + STEPS_PER_PAGE).min(pattern.length) - page_start;
|
layout: MainLayout,
|
||||||
let num_rows = steps_on_page.div_ceil(8);
|
show_scope: bool,
|
||||||
let spacing = num_rows.saturating_sub(1) as u16;
|
show_spectrum: bool,
|
||||||
let row_height = sequencer_area.height.saturating_sub(spacing) / num_rows as u16;
|
) {
|
||||||
let actual_grid_height = row_height * num_rows as u16 + spacing;
|
let is_vertical_layout = matches!(layout, MainLayout::Left | MainLayout::Right);
|
||||||
|
|
||||||
let aligned_vu_area = Rect {
|
if show_scope && show_spectrum {
|
||||||
y: sequencer_area.y,
|
if is_vertical_layout {
|
||||||
height: actual_grid_height,
|
let [scope_area, spectrum_area] = Layout::vertical([
|
||||||
..vu_area
|
Constraint::Fill(1),
|
||||||
};
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
render_vu_meter(frame, app, aligned_vu_area);
|
.areas(area);
|
||||||
|
render_scope(frame, app, scope_area, Orientation::Vertical);
|
||||||
|
render_spectrum(frame, app, spectrum_area);
|
||||||
|
} else {
|
||||||
|
let [scope_area, spectrum_area] = Layout::horizontal([
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
render_scope(frame, app, scope_area, Orientation::Horizontal);
|
||||||
|
render_spectrum(frame, app, spectrum_area);
|
||||||
|
}
|
||||||
|
} else if show_scope {
|
||||||
|
let orientation = if is_vertical_layout {
|
||||||
|
Orientation::Vertical
|
||||||
|
} else {
|
||||||
|
Orientation::Horizontal
|
||||||
|
};
|
||||||
|
render_scope(frame, app, area, orientation);
|
||||||
|
} else if show_spectrum {
|
||||||
|
render_spectrum(frame, app, area);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS_PER_PAGE: usize = 32;
|
const STEPS_PER_PAGE: usize = 32;
|
||||||
@@ -84,22 +136,15 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot,
|
|||||||
let num_rows = steps_on_page.div_ceil(8);
|
let num_rows = steps_on_page.div_ceil(8);
|
||||||
let steps_per_row = steps_on_page.div_ceil(num_rows);
|
let steps_per_row = steps_on_page.div_ceil(num_rows);
|
||||||
|
|
||||||
let spacing = num_rows.saturating_sub(1) as u16;
|
let row_height = area.height / num_rows as u16;
|
||||||
let row_height = area.height.saturating_sub(spacing) / num_rows as u16;
|
|
||||||
|
|
||||||
let row_constraints: Vec<Constraint> = (0..num_rows * 2 - 1)
|
let row_constraints: Vec<Constraint> = (0..num_rows)
|
||||||
.map(|i| {
|
.map(|_| Constraint::Length(row_height))
|
||||||
if i % 2 == 0 {
|
|
||||||
Constraint::Length(row_height)
|
|
||||||
} else {
|
|
||||||
Constraint::Length(1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
let rows = Layout::vertical(row_constraints).split(area);
|
let rows = Layout::vertical(row_constraints).split(area);
|
||||||
|
|
||||||
for row_idx in 0..num_rows {
|
for row_idx in 0..num_rows {
|
||||||
let row_area = rows[row_idx * 2];
|
let row_area = rows[row_idx];
|
||||||
let start_step = row_idx * steps_per_row;
|
let start_step = row_idx * steps_per_row;
|
||||||
let end_step = (start_step + steps_per_row).min(steps_on_page);
|
let end_step = (start_step + steps_per_row).min(steps_on_page);
|
||||||
let cols_in_row = end_step - start_step;
|
let cols_in_row = end_step - start_step;
|
||||||
@@ -174,6 +219,12 @@ fn render_tile(
|
|||||||
(false, false, false, _, _) => (theme.tile.inactive_bg, theme.tile.inactive_fg),
|
(false, false, false, _, _) => (theme.tile.inactive_bg, theme.tile.inactive_fg),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::new().fg(theme.ui.border));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let source_idx = step.and_then(|s| s.source);
|
let source_idx = step.and_then(|s| s.source);
|
||||||
let symbol = if is_playing {
|
let symbol = if is_playing {
|
||||||
"▶".to_string()
|
"▶".to_string()
|
||||||
@@ -191,17 +242,17 @@ fn render_tile(
|
|||||||
};
|
};
|
||||||
let num_lines = if step_name.is_some() { 2u16 } else { 1u16 };
|
let num_lines = if step_name.is_some() { 2u16 } else { 1u16 };
|
||||||
let content_height = num_lines;
|
let content_height = num_lines;
|
||||||
let y_offset = area.height.saturating_sub(content_height) / 2;
|
let y_offset = inner.height.saturating_sub(content_height) / 2;
|
||||||
|
|
||||||
// Fill background for entire tile
|
// Fill background for inner area
|
||||||
let bg_fill = Paragraph::new("").style(Style::new().bg(bg));
|
let bg_fill = Paragraph::new("").style(Style::new().bg(bg));
|
||||||
frame.render_widget(bg_fill, area);
|
frame.render_widget(bg_fill, inner);
|
||||||
|
|
||||||
if let Some(name) = step_name {
|
if let Some(name) = step_name {
|
||||||
let name_area = Rect {
|
let name_area = Rect {
|
||||||
x: area.x,
|
x: inner.x,
|
||||||
y: area.y + y_offset,
|
y: inner.y + y_offset,
|
||||||
width: area.width,
|
width: inner.width,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
let name_widget = Paragraph::new(name.as_str())
|
let name_widget = Paragraph::new(name.as_str())
|
||||||
@@ -210,9 +261,9 @@ fn render_tile(
|
|||||||
frame.render_widget(name_widget, name_area);
|
frame.render_widget(name_widget, name_area);
|
||||||
|
|
||||||
let symbol_area = Rect {
|
let symbol_area = Rect {
|
||||||
x: area.x,
|
x: inner.x,
|
||||||
y: area.y + y_offset + 1,
|
y: inner.y + y_offset + 1,
|
||||||
width: area.width,
|
width: inner.width,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
let symbol_widget = Paragraph::new(symbol)
|
let symbol_widget = Paragraph::new(symbol)
|
||||||
@@ -221,9 +272,9 @@ fn render_tile(
|
|||||||
frame.render_widget(symbol_widget, symbol_area);
|
frame.render_widget(symbol_widget, symbol_area);
|
||||||
} else {
|
} else {
|
||||||
let centered_area = Rect {
|
let centered_area = Rect {
|
||||||
x: area.x,
|
x: inner.x,
|
||||||
y: area.y + y_offset,
|
y: inner.y + y_offset,
|
||||||
width: area.width,
|
width: inner.width,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
let tile = Paragraph::new(symbol)
|
let tile = Paragraph::new(symbol)
|
||||||
@@ -233,21 +284,65 @@ fn render_tile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_scope(frame: &mut Frame, app: &App, area: Rect, orientation: Orientation) {
|
||||||
let theme = theme::get();
|
let theme = theme::get();
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::new().fg(theme.ui.border));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let scope = Scope::new(&app.metrics.scope)
|
let scope = Scope::new(&app.metrics.scope)
|
||||||
.orientation(Orientation::Horizontal)
|
.orientation(orientation)
|
||||||
.color(theme.meter.low);
|
.color(theme.meter.low);
|
||||||
frame.render_widget(scope, area);
|
frame.render_widget(scope, inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
let area = Rect { height: area.height.saturating_sub(1), ..area };
|
let theme = theme::get();
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::new().fg(theme.ui.border));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let spectrum = Spectrum::new(&app.metrics.spectrum);
|
let spectrum = Spectrum::new(&app.metrics.spectrum);
|
||||||
frame.render_widget(spectrum, area);
|
frame.render_widget(spectrum, inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let theme = theme::get();
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::new().fg(theme.ui.border));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right);
|
let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right);
|
||||||
frame.render_widget(vu, area);
|
frame.render_widget(vu, inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_active_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||||
|
let theme = theme::get();
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::new().fg(theme.ui.border));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let patterns: Vec<(usize, usize, usize)> = snapshot
|
||||||
|
.active_patterns
|
||||||
|
.iter()
|
||||||
|
.map(|p| (p.bank, p.pattern, p.iter))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let step_info = snapshot
|
||||||
|
.get_step(app.editor_ctx.bank, app.editor_ctx.pattern)
|
||||||
|
.map(|step| (step, app.current_edit_pattern().length));
|
||||||
|
|
||||||
|
let mut widget = ActivePatterns::new(&patterns);
|
||||||
|
if let Some((step, total)) = step_info {
|
||||||
|
widget = widget.with_step(step, total);
|
||||||
|
}
|
||||||
|
frame.render_widget(widget, inner);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
|||||||
let midi_in_2 = midi_in_display(2);
|
let midi_in_2 = midi_in_display(2);
|
||||||
let midi_in_3 = midi_in_display(3);
|
let midi_in_3 = midi_in_display(3);
|
||||||
|
|
||||||
|
let hue_str = format!("{}°", app.ui.hue_rotation as i32);
|
||||||
|
|
||||||
let lines: Vec<Line> = vec![
|
let lines: Vec<Line> = vec![
|
||||||
render_section_header("DISPLAY", &theme),
|
render_section_header("DISPLAY", &theme),
|
||||||
render_divider(content_width, &theme),
|
render_divider(content_width, &theme),
|
||||||
@@ -119,6 +121,12 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
|||||||
focus == OptionsFocus::ColorScheme,
|
focus == OptionsFocus::ColorScheme,
|
||||||
&theme,
|
&theme,
|
||||||
),
|
),
|
||||||
|
render_option_line(
|
||||||
|
"Hue rotation",
|
||||||
|
&hue_str,
|
||||||
|
focus == OptionsFocus::HueRotation,
|
||||||
|
&theme,
|
||||||
|
),
|
||||||
render_option_line(
|
render_option_line(
|
||||||
"Refresh rate",
|
"Refresh rate",
|
||||||
app.audio.config.refresh_rate.label(),
|
app.audio.config.refresh_rate.label(),
|
||||||
@@ -201,23 +209,24 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
|||||||
|
|
||||||
let focus_line: usize = match focus {
|
let focus_line: usize = match focus {
|
||||||
OptionsFocus::ColorScheme => 2,
|
OptionsFocus::ColorScheme => 2,
|
||||||
OptionsFocus::RefreshRate => 3,
|
OptionsFocus::HueRotation => 3,
|
||||||
OptionsFocus::RuntimeHighlight => 4,
|
OptionsFocus::RefreshRate => 4,
|
||||||
OptionsFocus::ShowScope => 5,
|
OptionsFocus::RuntimeHighlight => 5,
|
||||||
OptionsFocus::ShowSpectrum => 6,
|
OptionsFocus::ShowScope => 6,
|
||||||
OptionsFocus::ShowCompletion => 7,
|
OptionsFocus::ShowSpectrum => 7,
|
||||||
OptionsFocus::FlashBrightness => 8,
|
OptionsFocus::ShowCompletion => 8,
|
||||||
OptionsFocus::LinkEnabled => 12,
|
OptionsFocus::FlashBrightness => 9,
|
||||||
OptionsFocus::StartStopSync => 13,
|
OptionsFocus::LinkEnabled => 13,
|
||||||
OptionsFocus::Quantum => 14,
|
OptionsFocus::StartStopSync => 14,
|
||||||
OptionsFocus::MidiOutput0 => 25,
|
OptionsFocus::Quantum => 15,
|
||||||
OptionsFocus::MidiOutput1 => 26,
|
OptionsFocus::MidiOutput0 => 26,
|
||||||
OptionsFocus::MidiOutput2 => 27,
|
OptionsFocus::MidiOutput1 => 27,
|
||||||
OptionsFocus::MidiOutput3 => 28,
|
OptionsFocus::MidiOutput2 => 28,
|
||||||
OptionsFocus::MidiInput0 => 32,
|
OptionsFocus::MidiOutput3 => 29,
|
||||||
OptionsFocus::MidiInput1 => 33,
|
OptionsFocus::MidiInput0 => 33,
|
||||||
OptionsFocus::MidiInput2 => 34,
|
OptionsFocus::MidiInput1 => 34,
|
||||||
OptionsFocus::MidiInput3 => 35,
|
OptionsFocus::MidiInput2 => 35,
|
||||||
|
OptionsFocus::MidiInput3 => 36,
|
||||||
};
|
};
|
||||||
|
|
||||||
let scroll_offset = if total_lines <= max_visible {
|
let scroll_offset = if total_lines <= max_visible {
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ use crate::app::App;
|
|||||||
use crate::engine::{LinkState, SequencerSnapshot};
|
use crate::engine::{LinkState, SequencerSnapshot};
|
||||||
use crate::model::{SourceSpan, StepContext, Value};
|
use crate::model::{SourceSpan, StepContext, Value};
|
||||||
use crate::page::Page;
|
use crate::page::Page;
|
||||||
use crate::state::{FlashKind, Modal, PanelFocus, PatternField, SidePanel, StackCache};
|
use crate::state::{
|
||||||
|
EuclideanField, FlashKind, Modal, PanelFocus, PatternField, SidePanel, StackCache,
|
||||||
|
};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime};
|
use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime};
|
||||||
use crate::widgets::{
|
use crate::widgets::{
|
||||||
@@ -1038,5 +1040,131 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
|||||||
hint_area,
|
hint_area,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Modal::EuclideanDistribution {
|
||||||
|
source_step,
|
||||||
|
field,
|
||||||
|
pulses,
|
||||||
|
steps,
|
||||||
|
rotation,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let width = 50u16;
|
||||||
|
let height = 11u16;
|
||||||
|
let x = (term.width.saturating_sub(width)) / 2;
|
||||||
|
let y = (term.height.saturating_sub(height)) / 2;
|
||||||
|
let area = Rect::new(x, y, width, height);
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(format!(" Euclidean Distribution (Step {:02}) ", source_step + 1))
|
||||||
|
.border_style(Style::default().fg(theme.modal.input));
|
||||||
|
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
|
||||||
|
// Fill background with theme color
|
||||||
|
let bg_fill = " ".repeat(area.width as usize);
|
||||||
|
for row in 0..area.height {
|
||||||
|
let line_area = Rect::new(area.x, area.y + row, area.width, 1);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(bg_fill.clone()).style(Style::new().bg(theme.ui.bg)),
|
||||||
|
line_area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let fields = [
|
||||||
|
(
|
||||||
|
"Pulses",
|
||||||
|
pulses.as_str(),
|
||||||
|
*field == EuclideanField::Pulses,
|
||||||
|
),
|
||||||
|
("Steps", steps.as_str(), *field == EuclideanField::Steps),
|
||||||
|
(
|
||||||
|
"Rotation",
|
||||||
|
rotation.as_str(),
|
||||||
|
*field == EuclideanField::Rotation,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (i, (label, value, selected)) in fields.iter().enumerate() {
|
||||||
|
let row_y = inner.y + i as u16;
|
||||||
|
if row_y >= inner.y + inner.height {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (label_style, value_style) = if *selected {
|
||||||
|
(
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.hint.key)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.ui.text_primary)
|
||||||
|
.bg(theme.ui.surface),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
Style::default().fg(theme.ui.text_muted),
|
||||||
|
Style::default().fg(theme.ui.text_primary),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let label_area = Rect::new(inner.x + 1, row_y, 14, 1);
|
||||||
|
let value_area = Rect::new(inner.x + 16, row_y, inner.width.saturating_sub(18), 1);
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(format!("{label}:")).style(label_style),
|
||||||
|
label_area,
|
||||||
|
);
|
||||||
|
frame.render_widget(Paragraph::new(*value).style(value_style), value_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
let preview_y = inner.y + 4;
|
||||||
|
if preview_y < inner.y + inner.height {
|
||||||
|
let pulses_val: usize = pulses.parse().unwrap_or(0);
|
||||||
|
let steps_val: usize = steps.parse().unwrap_or(0);
|
||||||
|
let rotation_val: usize = rotation.parse().unwrap_or(0);
|
||||||
|
let preview = format_euclidean_preview(pulses_val, steps_val, rotation_val);
|
||||||
|
let preview_line = Line::from(vec![
|
||||||
|
Span::styled("Preview: ", Style::default().fg(theme.ui.text_muted)),
|
||||||
|
Span::styled(preview, Style::default().fg(theme.modal.input)),
|
||||||
|
]);
|
||||||
|
let preview_area =
|
||||||
|
Rect::new(inner.x + 1, preview_y, inner.width.saturating_sub(2), 1);
|
||||||
|
frame.render_widget(Paragraph::new(preview_line), preview_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
|
||||||
|
let hint_line = Line::from(vec![
|
||||||
|
Span::styled("↑↓", Style::default().fg(theme.hint.key)),
|
||||||
|
Span::styled(" nav ", Style::default().fg(theme.hint.text)),
|
||||||
|
Span::styled("←→", Style::default().fg(theme.hint.key)),
|
||||||
|
Span::styled(" adjust ", Style::default().fg(theme.hint.text)),
|
||||||
|
Span::styled("Enter", Style::default().fg(theme.hint.key)),
|
||||||
|
Span::styled(" apply ", Style::default().fg(theme.hint.text)),
|
||||||
|
Span::styled("Esc", Style::default().fg(theme.hint.key)),
|
||||||
|
Span::styled(" cancel", Style::default().fg(theme.hint.text)),
|
||||||
|
]);
|
||||||
|
frame.render_widget(Paragraph::new(hint_line), hint_area);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_euclidean_preview(pulses: usize, steps: usize, rotation: usize) -> String {
|
||||||
|
if pulses == 0 || steps == 0 || pulses > steps {
|
||||||
|
return "[invalid]".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pattern = vec![false; steps];
|
||||||
|
for i in 0..pulses {
|
||||||
|
let pos = (i * steps) / pulses;
|
||||||
|
pattern[pos] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if rotation > 0 {
|
||||||
|
pattern.rotate_left(rotation % steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
let chars: Vec<&str> = pattern.iter().map(|&h| if h { "x" } else { "." }).collect();
|
||||||
|
format!("[{}]", chars.join(" "))
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use ratatui::style::Style;
|
|||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
#[cfg(not(feature = "desktop"))]
|
||||||
use tui_big_text::{BigText, PixelSize};
|
use tui_big_text::{BigText, PixelSize};
|
||||||
|
|
||||||
use crate::state::ui::UiState;
|
use crate::state::ui::UiState;
|
||||||
@@ -16,13 +17,21 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) {
|
|||||||
let link_style = Style::new().fg(theme.title.link);
|
let link_style = Style::new().fg(theme.title.link);
|
||||||
let license_style = Style::new().fg(theme.title.license);
|
let license_style = Style::new().fg(theme.title.license);
|
||||||
|
|
||||||
|
#[cfg(not(feature = "desktop"))]
|
||||||
let big_title = BigText::builder()
|
let big_title = BigText::builder()
|
||||||
.pixel_size(PixelSize::Quadrant)
|
.pixel_size(PixelSize::Full)
|
||||||
.style(Style::new().fg(theme.title.big_title).bold())
|
.style(Style::new().fg(theme.title.big_title).bold())
|
||||||
.lines(vec!["CAGIRE".into()])
|
.lines(vec!["CAGIRE".into()])
|
||||||
.centered()
|
.centered()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
let big_title = Paragraph::new(Line::from(Span::styled(
|
||||||
|
"CAGIRE",
|
||||||
|
Style::new().fg(theme.title.big_title).bold(),
|
||||||
|
)))
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
|
||||||
let version_style = Style::new().fg(theme.title.subtitle);
|
let version_style = Style::new().fg(theme.title.subtitle);
|
||||||
|
|
||||||
let subtitle_lines = vec![
|
let subtitle_lines = vec![
|
||||||
@@ -49,21 +58,43 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) {
|
|||||||
)),
|
)),
|
||||||
];
|
];
|
||||||
|
|
||||||
let big_text_height = 4;
|
#[cfg(not(feature = "desktop"))]
|
||||||
|
let big_text_height = 8;
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
let big_text_height = 1;
|
||||||
|
let min_title_width = 30;
|
||||||
let subtitle_height = subtitle_lines.len() as u16;
|
let subtitle_height = subtitle_lines.len() as u16;
|
||||||
let total_height = big_text_height + subtitle_height;
|
|
||||||
let vertical_padding = area.height.saturating_sub(total_height) / 2;
|
|
||||||
|
|
||||||
let [_, title_area, subtitle_area, _] = Layout::vertical([
|
let show_big_title =
|
||||||
Constraint::Length(vertical_padding),
|
area.height >= (big_text_height + subtitle_height) && area.width >= min_title_width;
|
||||||
Constraint::Length(big_text_height),
|
|
||||||
Constraint::Length(subtitle_height),
|
|
||||||
Constraint::Fill(1),
|
|
||||||
])
|
|
||||||
.areas(area);
|
|
||||||
|
|
||||||
frame.render_widget(big_title, title_area);
|
if show_big_title {
|
||||||
|
let total_height = big_text_height + subtitle_height;
|
||||||
|
let vertical_padding = area.height.saturating_sub(total_height) / 2;
|
||||||
|
|
||||||
let subtitle = Paragraph::new(subtitle_lines).alignment(Alignment::Center);
|
let [_, title_area, subtitle_area, _] = Layout::vertical([
|
||||||
frame.render_widget(subtitle, subtitle_area);
|
Constraint::Length(vertical_padding),
|
||||||
|
Constraint::Length(big_text_height),
|
||||||
|
Constraint::Length(subtitle_height),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
|
||||||
|
frame.render_widget(big_title, title_area);
|
||||||
|
|
||||||
|
let subtitle = Paragraph::new(subtitle_lines).alignment(Alignment::Center);
|
||||||
|
frame.render_widget(subtitle, subtitle_area);
|
||||||
|
} else {
|
||||||
|
let vertical_padding = area.height.saturating_sub(subtitle_height) / 2;
|
||||||
|
|
||||||
|
let [_, subtitle_area, _] = Layout::vertical([
|
||||||
|
Constraint::Length(vertical_padding),
|
||||||
|
Constraint::Length(subtitle_height),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
|
||||||
|
let subtitle = Paragraph::new(subtitle_lines).alignment(Alignment::Center);
|
||||||
|
frame.render_widget(subtitle, subtitle_area);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
pub use cagire_ratatui::{
|
pub use cagire_ratatui::{
|
||||||
ConfirmModal, FileBrowserModal, ModalFrame, NavMinimap, NavTile, Orientation, SampleBrowser,
|
ActivePatterns, ConfirmModal, FileBrowserModal, ModalFrame, NavMinimap, NavTile, Orientation,
|
||||||
Scope, Spectrum, TextInputModal, VuMeter,
|
SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,3 +54,6 @@ mod generator;
|
|||||||
|
|
||||||
#[path = "forth/midi.rs"]
|
#[path = "forth/midi.rs"]
|
||||||
mod midi;
|
mod midi;
|
||||||
|
|
||||||
|
#[path = "forth/chords.rs"]
|
||||||
|
mod chords;
|
||||||
|
|||||||
178
tests/forth/chords.rs
Normal file
178
tests/forth/chords.rs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
use cagire::forth::Value;
|
||||||
|
|
||||||
|
use super::harness::{expect_stack, run};
|
||||||
|
|
||||||
|
fn ints(vals: &[i64]) -> Vec<Value> {
|
||||||
|
vals.iter().map(|&v| Value::Int(v, None)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Triads
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_major() {
|
||||||
|
expect_stack("c4 maj", &ints(&[60, 64, 67]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_minor() {
|
||||||
|
expect_stack("c4 m", &ints(&[60, 63, 67]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_diminished() {
|
||||||
|
expect_stack("c4 dim", &ints(&[60, 63, 66]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_augmented() {
|
||||||
|
expect_stack("c4 aug", &ints(&[60, 64, 68]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_sus2() {
|
||||||
|
expect_stack("c4 sus2", &ints(&[60, 62, 67]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_sus4() {
|
||||||
|
expect_stack("c4 sus4", &ints(&[60, 65, 67]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seventh chords
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_maj7() {
|
||||||
|
expect_stack("c4 maj7", &ints(&[60, 64, 67, 71]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_min7() {
|
||||||
|
expect_stack("c4 min7", &ints(&[60, 63, 67, 70]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_dom7() {
|
||||||
|
expect_stack("c4 dom7", &ints(&[60, 64, 67, 70]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_dim7() {
|
||||||
|
expect_stack("c4 dim7", &ints(&[60, 63, 66, 69]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_half_dim() {
|
||||||
|
expect_stack("c4 m7b5", &ints(&[60, 63, 66, 70]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_minmaj7() {
|
||||||
|
expect_stack("c4 minmaj7", &ints(&[60, 63, 67, 71]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_aug7() {
|
||||||
|
expect_stack("c4 aug7", &ints(&[60, 64, 68, 70]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sixth chords
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_maj6() {
|
||||||
|
expect_stack("c4 maj6", &ints(&[60, 64, 67, 69]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_min6() {
|
||||||
|
expect_stack("c4 min6", &ints(&[60, 63, 67, 69]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended chords
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_dom9() {
|
||||||
|
expect_stack("c4 dom9", &ints(&[60, 64, 67, 70, 74]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_maj9() {
|
||||||
|
expect_stack("c4 maj9", &ints(&[60, 64, 67, 71, 74]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_min9() {
|
||||||
|
expect_stack("c4 min9", &ints(&[60, 63, 67, 70, 74]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_dom11() {
|
||||||
|
expect_stack("c4 dom11", &ints(&[60, 64, 67, 70, 74, 77]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_min11() {
|
||||||
|
expect_stack("c4 min11", &ints(&[60, 63, 67, 70, 74, 77]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_dom13() {
|
||||||
|
expect_stack("c4 dom13", &ints(&[60, 64, 67, 70, 74, 81]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add chords
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_add9() {
|
||||||
|
expect_stack("c4 add9", &ints(&[60, 64, 67, 74]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_add11() {
|
||||||
|
expect_stack("c4 add11", &ints(&[60, 64, 67, 77]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_madd9() {
|
||||||
|
expect_stack("c4 madd9", &ints(&[60, 63, 67, 74]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Altered dominants
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_dom7b9() {
|
||||||
|
expect_stack("c4 dom7b9", &ints(&[60, 64, 67, 70, 73]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_dom7s9() {
|
||||||
|
expect_stack("c4 dom7s9", &ints(&[60, 64, 67, 70, 75]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_dom7b5() {
|
||||||
|
expect_stack("c4 dom7b5", &ints(&[60, 64, 66, 70]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_dom7s5() {
|
||||||
|
expect_stack("c4 dom7s5", &ints(&[60, 64, 68, 70]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different roots
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_a3_min7() {
|
||||||
|
expect_stack("a3 min7", &ints(&[57, 60, 64, 67]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_e4_dom7s9() {
|
||||||
|
expect_stack("e4 dom7s9", &ints(&[64, 68, 71, 74, 79]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chord_with_integer_root() {
|
||||||
|
let f = run("60 maj");
|
||||||
|
let stack = f.stack();
|
||||||
|
assert_eq!(stack, ints(&[60, 64, 67]));
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::harness::*;
|
use super::harness::*;
|
||||||
|
use cagire::forth::Value;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn define_and_use_word() {
|
fn define_and_use_word() {
|
||||||
@@ -113,3 +114,44 @@ fn define_word_with_conditional() {
|
|||||||
f.evaluate("10 maybe-double", &ctx).unwrap();
|
f.evaluate("10 maybe-double", &ctx).unwrap();
|
||||||
assert_eq!(stack_int(&f), 20);
|
assert_eq!(stack_int(&f), 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forget_removes_word() {
|
||||||
|
let f = forth();
|
||||||
|
let ctx = default_ctx();
|
||||||
|
f.evaluate(": double 2 * ;", &ctx).unwrap();
|
||||||
|
f.evaluate("5 double", &ctx).unwrap();
|
||||||
|
assert_eq!(stack_int(&f), 10);
|
||||||
|
f.clear_stack();
|
||||||
|
f.evaluate("\"double\" forget", &ctx).unwrap();
|
||||||
|
f.evaluate("double", &ctx).unwrap();
|
||||||
|
let stack = f.stack();
|
||||||
|
assert_eq!(stack.len(), 1);
|
||||||
|
match &stack[0] {
|
||||||
|
Value::Str(s, _) => assert_eq!(s, "double"),
|
||||||
|
other => panic!("expected Str, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forget_nonexistent_is_noop() {
|
||||||
|
let f = forth();
|
||||||
|
let ctx = default_ctx();
|
||||||
|
f.evaluate("\"nosuchword\" forget", &ctx).unwrap();
|
||||||
|
f.evaluate("42", &ctx).unwrap();
|
||||||
|
assert_eq!(stack_int(&f), 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forget_and_redefine() {
|
||||||
|
let f = forth();
|
||||||
|
let ctx = default_ctx();
|
||||||
|
f.evaluate(": foo 10 ;", &ctx).unwrap();
|
||||||
|
f.evaluate("foo", &ctx).unwrap();
|
||||||
|
assert_eq!(stack_int(&f), 10);
|
||||||
|
f.clear_stack();
|
||||||
|
f.evaluate("\"foo\" forget", &ctx).unwrap();
|
||||||
|
f.evaluate(": foo 20 ;", &ctx).unwrap();
|
||||||
|
f.evaluate("foo", &ctx).unwrap();
|
||||||
|
assert_eq!(stack_int(&f), 20);
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,22 +74,6 @@ fn dupn_alias() {
|
|||||||
expect_int("5 3 ! + +", 15);
|
expect_int("5 3 ! + +", 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tcycle_creates_cycle_list() {
|
|
||||||
let outputs = expect_outputs(r#"0.0 at 60 64 67 3 tcycle note sine s ."#, 1);
|
|
||||||
assert!(outputs[0].contains("note/60"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tcycle_with_multiple_emits() {
|
|
||||||
let f = forth();
|
|
||||||
let ctx = default_ctx();
|
|
||||||
let outputs = f.evaluate(r#"0 0.5 2 at 60 64 2 tcycle note sine s ."#, &ctx).unwrap();
|
|
||||||
assert_eq!(outputs.len(), 2);
|
|
||||||
assert!(outputs[0].contains("note/60"));
|
|
||||||
assert!(outputs[1].contains("note/64"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cycle_zero_count_error() {
|
fn cycle_zero_count_error() {
|
||||||
expect_error("1 2 3 0 cycle", "cycle count must be > 0");
|
expect_error("1 2 3 0 cycle", "cycle count must be > 0");
|
||||||
@@ -99,8 +83,3 @@ fn cycle_zero_count_error() {
|
|||||||
fn choose_zero_count_error() {
|
fn choose_zero_count_error() {
|
||||||
expect_error("1 2 3 0 choose", "choose count must be > 0");
|
expect_error("1 2 3 0 choose", "choose count must be > 0");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tcycle_zero_count_error() {
|
|
||||||
expect_error("1 2 3 0 tcycle", "tcycle count must be > 0");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -105,3 +105,74 @@ fn ftom_880() {
|
|||||||
fn mtof_ftom_roundtrip() {
|
fn mtof_ftom_roundtrip() {
|
||||||
expect_float("60 mtof ftom", 60.0);
|
expect_float("60 mtof ftom", 60.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exprand_in_range() {
|
||||||
|
let f = forth_seeded(12345);
|
||||||
|
f.evaluate("1.0 100.0 exprand", &default_ctx()).unwrap();
|
||||||
|
let val = stack_float(&f);
|
||||||
|
assert!(val >= 1.0 && val <= 100.0, "exprand {} not in [1, 100]", val);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exprand_deterministic() {
|
||||||
|
let f1 = forth_seeded(99);
|
||||||
|
let f2 = forth_seeded(99);
|
||||||
|
f1.evaluate("1.0 100.0 exprand", &default_ctx()).unwrap();
|
||||||
|
f2.evaluate("1.0 100.0 exprand", &default_ctx()).unwrap();
|
||||||
|
assert_eq!(f1.stack(), f2.stack());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exprand_swapped_args() {
|
||||||
|
let f1 = forth_seeded(42);
|
||||||
|
let f2 = forth_seeded(42);
|
||||||
|
f1.evaluate("1.0 100.0 exprand", &default_ctx()).unwrap();
|
||||||
|
f2.evaluate("100.0 1.0 exprand", &default_ctx()).unwrap();
|
||||||
|
assert_eq!(f1.stack(), f2.stack());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exprand_requires_positive() {
|
||||||
|
expect_error("0.0 10.0 exprand", "exprand requires positive values");
|
||||||
|
expect_error("-1.0 10.0 exprand", "exprand requires positive values");
|
||||||
|
expect_error("1.0 0.0 exprand", "exprand requires positive values");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logrand_in_range() {
|
||||||
|
let f = forth_seeded(12345);
|
||||||
|
f.evaluate("1.0 100.0 logrand", &default_ctx()).unwrap();
|
||||||
|
let val = stack_float(&f);
|
||||||
|
assert!(val >= 1.0 && val <= 100.0, "logrand {} not in [1, 100]", val);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logrand_deterministic() {
|
||||||
|
let f1 = forth_seeded(99);
|
||||||
|
let f2 = forth_seeded(99);
|
||||||
|
f1.evaluate("1.0 100.0 logrand", &default_ctx()).unwrap();
|
||||||
|
f2.evaluate("1.0 100.0 logrand", &default_ctx()).unwrap();
|
||||||
|
assert_eq!(f1.stack(), f2.stack());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logrand_swapped_args() {
|
||||||
|
let f1 = forth_seeded(42);
|
||||||
|
let f2 = forth_seeded(42);
|
||||||
|
f1.evaluate("1.0 100.0 logrand", &default_ctx()).unwrap();
|
||||||
|
f2.evaluate("100.0 1.0 logrand", &default_ctx()).unwrap();
|
||||||
|
assert_eq!(f1.stack(), f2.stack());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logrand_requires_positive() {
|
||||||
|
expect_error("0.0 10.0 logrand", "logrand requires positive values");
|
||||||
|
expect_error("-1.0 10.0 logrand", "logrand requires positive values");
|
||||||
|
expect_error("1.0 0.0 logrand", "logrand requires positive values");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rand_equal_bounds() {
|
||||||
|
expect_float("5.0 5.0 rand", 5.0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,3 +106,35 @@ fn param_only_multiple_params() {
|
|||||||
assert!(outputs[0].contains("gain/0.5"));
|
assert!(outputs[0].contains("gain/0.5"));
|
||||||
assert!(!outputs[0].contains("sound/"));
|
assert!(!outputs[0].contains("sound/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn polyphonic_notes() {
|
||||||
|
let outputs = expect_outputs(r#"60 64 67 note sine s ."#, 3);
|
||||||
|
assert!(outputs[0].contains("note/60"));
|
||||||
|
assert!(outputs[1].contains("note/64"));
|
||||||
|
assert!(outputs[2].contains("note/67"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn polyphonic_sounds() {
|
||||||
|
let outputs = expect_outputs(r#"440 freq kick hat s ."#, 2);
|
||||||
|
assert!(outputs[0].contains("sound/kick"));
|
||||||
|
assert!(outputs[1].contains("sound/hat"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn polyphonic_cycling() {
|
||||||
|
let outputs = expect_outputs(r#"60 64 67 note 0.5 1.0 gain sine s ."#, 3);
|
||||||
|
assert!(outputs[0].contains("note/60"));
|
||||||
|
assert!(outputs[0].contains("gain/0.5"));
|
||||||
|
assert!(outputs[1].contains("note/64"));
|
||||||
|
assert!(outputs[1].contains("gain/1"));
|
||||||
|
assert!(outputs[2].contains("note/67"));
|
||||||
|
assert!(outputs[2].contains("gain/0.5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn polyphonic_with_at() {
|
||||||
|
let outputs = expect_outputs(r#"0 0.5 at 60 64 note sine s ."#, 4);
|
||||||
|
assert_eq!(outputs.len(), 4);
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,6 +95,46 @@ fn tuck_underflow() {
|
|||||||
expect_error("1 tuck", "stack underflow");
|
expect_error("1 tuck", "stack underflow");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dup2() {
|
||||||
|
expect_stack("1 2 2dup", &[int(1), int(2), int(1), int(2)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dup2_underflow() {
|
||||||
|
expect_error("1 2dup", "stack underflow");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop2() {
|
||||||
|
expect_stack("1 2 3 2drop", &[int(1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop2_underflow() {
|
||||||
|
expect_error("1 2drop", "stack underflow");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swap2() {
|
||||||
|
expect_stack("1 2 3 4 2swap", &[int(3), int(4), int(1), int(2)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swap2_underflow() {
|
||||||
|
expect_error("1 2 3 2swap", "stack underflow");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn over2() {
|
||||||
|
expect_stack("1 2 3 4 2over", &[int(1), int(2), int(3), int(4), int(1), int(2)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn over2_underflow() {
|
||||||
|
expect_error("1 2 3 2over", "stack underflow");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stack_persists() {
|
fn stack_persists() {
|
||||||
let f = forth();
|
let f = forth();
|
||||||
|
|||||||
@@ -42,13 +42,6 @@ fn get_sounds(outputs: &[String]) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_param(outputs: &[String], param: &str) -> Vec<f64> {
|
|
||||||
outputs
|
|
||||||
.iter()
|
|
||||||
.map(|o| parse_params(o).get(param).copied().unwrap_or(0.0))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
const EPSILON: f64 = 1e-9;
|
const EPSILON: f64 = 1e-9;
|
||||||
|
|
||||||
fn approx_eq(a: f64, b: f64) -> bool {
|
fn approx_eq(a: f64, b: f64) -> bool {
|
||||||
@@ -156,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 2 at "kick" s ."#, 2);
|
let outputs = expect_outputs(r#"0 0.5 at "kick" s ."#, 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]);
|
||||||
@@ -165,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 3 at "kick" s ."#, 3);
|
let outputs = expect_outputs(r#"0 0.33 0.67 at "kick" s ."#, 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");
|
||||||
@@ -175,70 +168,26 @@ fn at_three_deltas() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn at_persists_across_emits() {
|
fn at_persists_across_emits() {
|
||||||
let outputs = expect_outputs(r#"0 0.5 2 at "kick" s . "hat" s ."#, 4);
|
let outputs = expect_outputs(r#"0 0.5 at "kick" s . "hat" s ."#, 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"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tcycle_basic() {
|
|
||||||
let outputs = expect_outputs(r#"0 0.5 0.75 3 at 60 64 67 3 tcycle note sine s ."#, 3);
|
|
||||||
let notes = get_param(&outputs, "note");
|
|
||||||
assert_eq!(notes, vec![60.0, 64.0, 67.0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tcycle_wraps() {
|
|
||||||
let outputs = expect_outputs(r#"0 0.33 0.67 3 at 60 64 2 tcycle note sine s ."#, 3);
|
|
||||||
let notes = get_param(&outputs, "note");
|
|
||||||
assert_eq!(notes, vec![60.0, 64.0, 60.0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tcycle_with_sound() {
|
|
||||||
let outputs = expect_outputs(r#"0 0.5 2 at kick hat 2 tcycle s ."#, 2);
|
|
||||||
let sounds = get_sounds(&outputs);
|
|
||||||
assert_eq!(sounds, vec!["kick", "hat"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tcycle_multiple_params() {
|
|
||||||
let outputs = expect_outputs(r#"0 0.5 0.75 3 at 60 64 67 3 tcycle note 0.5 1.0 2 tcycle gain sine s ."#, 3);
|
|
||||||
let notes = get_param(&outputs, "note");
|
|
||||||
let gains = get_param(&outputs, "gain");
|
|
||||||
assert_eq!(notes, vec![60.0, 64.0, 67.0]);
|
|
||||||
assert_eq!(gains, vec![0.5, 1.0, 0.5]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn at_reset_with_zero() {
|
fn at_reset_with_zero() {
|
||||||
let outputs = expect_outputs(r#"0 0.5 2 at "kick" s . 0.0 at "hat" s ."#, 3);
|
let outputs = expect_outputs(r#"0 0.5 at "kick" s . 0.0 at "hat" s ."#, 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]
|
|
||||||
fn tcycle_records_selected_spans() {
|
|
||||||
use cagire::forth::ExecutionTrace;
|
|
||||||
|
|
||||||
let f = forth();
|
|
||||||
let mut trace = ExecutionTrace::default();
|
|
||||||
let script = r#"0 0.5 2 at kick hat 2 tcycle s ."#;
|
|
||||||
f.evaluate_with_trace(script, &default_ctx(), &mut trace).unwrap();
|
|
||||||
|
|
||||||
// Should have 4 selected spans:
|
|
||||||
// - 2 for at deltas (0 and 0.5)
|
|
||||||
// - 2 for tcycle sound values (kick and hat)
|
|
||||||
assert_eq!(trace.selected_spans.len(), 4, "expected 4 selected spans (2 at + 2 tcycle)");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn at_records_selected_spans() {
|
fn at_records_selected_spans() {
|
||||||
use cagire::forth::ExecutionTrace;
|
use cagire::forth::ExecutionTrace;
|
||||||
|
|
||||||
let f = forth();
|
let f = forth();
|
||||||
let mut trace = ExecutionTrace::default();
|
let mut trace = ExecutionTrace::default();
|
||||||
let script = r#"0 0.5 0.75 3 at "kick" s ."#;
|
let script = r#"0 0.5 0.75 at "kick" s ."#;
|
||||||
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)
|
||||||
|
|||||||
54
website/public/script.js
Normal file
54
website/public/script.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
const toggle = document.getElementById('theme-toggle');
|
||||||
|
const root = document.documentElement;
|
||||||
|
const stored = localStorage.getItem('theme');
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
const isLight = stored ? stored === 'light' : !prefersDark;
|
||||||
|
|
||||||
|
if (isLight) {
|
||||||
|
root.classList.add('light');
|
||||||
|
}
|
||||||
|
toggle.textContent = isLight ? 'DARK' : 'LIGHT';
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
root.classList.toggle('light');
|
||||||
|
const light = root.classList.contains('light');
|
||||||
|
toggle.textContent = light ? 'DARK' : 'LIGHT';
|
||||||
|
localStorage.setItem('theme', light ? 'light' : 'dark');
|
||||||
|
highlightForth();
|
||||||
|
});
|
||||||
|
|
||||||
|
function highlightForth() {
|
||||||
|
const words = ['note', 'sound', 'lpf', 'hpf', 'chorus', 'verb', 'distort', 'speed'];
|
||||||
|
const notes = ['c4'];
|
||||||
|
const chords = ['min7'];
|
||||||
|
const samples = ['kkick', 'sine', 'saw'];
|
||||||
|
const isLight = document.documentElement.classList.contains('light');
|
||||||
|
const numColor = isLight ? '#a855f7' : '#e8a0e8';
|
||||||
|
const dotColor = isLight ? '#0284c7' : '#7dd3fc';
|
||||||
|
const wordColor = isLight ? '#65a30d' : '#a3e635';
|
||||||
|
const noteColor = isLight ? '#d97706' : '#fbbf24';
|
||||||
|
const chordColor = isLight ? '#15803d' : '#4ade80';
|
||||||
|
const sampleColor = isLight ? '#dc2626' : '#f87171';
|
||||||
|
document.querySelectorAll('pre').forEach(pre => {
|
||||||
|
const text = pre.dataset.source || pre.textContent;
|
||||||
|
pre.dataset.source = text;
|
||||||
|
pre.innerHTML = text
|
||||||
|
.split(/(\s+)/)
|
||||||
|
.map(t => {
|
||||||
|
if (t === '.') return `<span style="color:${dotColor}">.</span>`;
|
||||||
|
if (/^-?\d+\.?\d*$/.test(t)) return `<span style="color:${numColor}">${t}</span>`;
|
||||||
|
if (words.includes(t)) return `<span style="color:${wordColor}">${t}</span>`;
|
||||||
|
if (notes.includes(t)) return `<span style="color:${noteColor}">${t}</span>`;
|
||||||
|
if (chords.includes(t)) return `<span style="color:${chordColor}">${t}</span>`;
|
||||||
|
if (samples.includes(t)) return `<span style="color:${sampleColor}">${t}</span>`;
|
||||||
|
return t;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', highlightForth);
|
||||||
|
} else {
|
||||||
|
highlightForth();
|
||||||
|
}
|
||||||
127
website/public/style.css
Normal file
127
website/public/style.css
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'CozetteVector';
|
||||||
|
src: url('/CozetteVector.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #000;
|
||||||
|
--surface: #121212;
|
||||||
|
--text: #fff;
|
||||||
|
--text-dim: #b4b4b4;
|
||||||
|
--text-muted: #787878;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.light {
|
||||||
|
--bg: #fff;
|
||||||
|
--surface: #f0f0f0;
|
||||||
|
--text: #000;
|
||||||
|
--text-dim: #505050;
|
||||||
|
--text-muted: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'CozetteVector', monospace;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: var(--text);
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p { margin: 0.25rem 0; }
|
||||||
|
|
||||||
|
video {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--text-dim); }
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li { margin: 0.1rem 0; }
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-table th,
|
||||||
|
.downloads-table td {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-table th {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-table td:first-child {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-table tr:nth-child(even) {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support {
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support a {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#theme-toggle {
|
||||||
|
font-family: 'CozetteVector', monospace;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#theme-toggle:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
@@ -18,137 +18,12 @@
|
|||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary">
|
||||||
<meta name="twitter:title" content="Cagire - Forth-based live coding sequencer">
|
<meta name="twitter:title" content="Cagire - Forth-based live coding sequencer">
|
||||||
<meta name="twitter:description" content="Forth-based live coding music sequencer">
|
<meta name="twitter:description" content="Forth-based live coding music sequencer">
|
||||||
<style>
|
<link rel="stylesheet" href="/style.css">
|
||||||
@font-face {
|
|
||||||
font-family: 'CozetteVector';
|
|
||||||
src: url('/CozetteVector.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #000;
|
|
||||||
--surface: #121212;
|
|
||||||
--text: #fff;
|
|
||||||
--text-dim: #b4b4b4;
|
|
||||||
--text-muted: #787878;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light {
|
|
||||||
--bg: #fff;
|
|
||||||
--surface: #f0f0f0;
|
|
||||||
--text: #000;
|
|
||||||
--text-dim: #505050;
|
|
||||||
--text-muted: #8c8c8c;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'CozetteVector', monospace;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: var(--text);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
color: var(--text);
|
|
||||||
margin-top: 1rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p { margin: 0.25rem 0; }
|
|
||||||
|
|
||||||
video {
|
|
||||||
max-width: 100%;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
a { color: var(--text-dim); }
|
|
||||||
|
|
||||||
ul {
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
li { margin: 0.1rem 0; }
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background: var(--surface);
|
|
||||||
padding: 0.5rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloads-table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloads-table th,
|
|
||||||
.downloads-table td {
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloads-table th {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloads-table td:first-child {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloads-table tr:nth-child(even) {
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.note {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note a {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
#theme-toggle {
|
|
||||||
font-family: 'CozetteVector', monospace;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--text-muted);
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#theme-toggle:hover {
|
|
||||||
background: var(--text-muted);
|
|
||||||
color: var(--bg);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<h1>CAGIRE: LIVE CODING IN FORTH</h1>
|
||||||
<h1>CAGIRE: LIVE CODING IN FORTH</h1>
|
|
||||||
<button id="theme-toggle" aria-label="Toggle theme">LIGHT</button>
|
<p class="support">Cagire is free and open source. If you find it useful, consider <a href="https://ko-fi.com/raphaelbubo">supporting the project on Ko-fi</a>.</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="downloads-table">
|
<table class="downloads-table">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -177,31 +52,43 @@
|
|||||||
<td><a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-linux-x86_64.tar.gz">.tar.gz</a></td>
|
<td><a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-linux-x86_64.tar.gz">.tar.gz</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p class="note">All releases on <a href="https://github.com/Bubobubobubobubo/cagire/releases/latest">GitHub</a>. You can also compile the software yourself or get it from Cargo!</p>
|
<p class="note">All releases are available on <a href="https://github.com/Bubobubobubobubo/cagire/releases/latest">GitHub</a>. You can also compile the software yourself by getting it from Cargo!</p>
|
||||||
|
|
||||||
<video src="/mono_cagire.mp4" autoplay muted loop playsinline></video>
|
<video src="/mono_cagire.mp4" autoplay muted loop playsinline></video>
|
||||||
|
|
||||||
<h2>About</h2>
|
<h2>About</h2>
|
||||||
<p>Cagire is a step sequencer where each step contains a Forth script instead of typical note data. When the sequencer reaches a step, it runs the script. Scripts can produce sound, trigger samples, apply effects, or do nothing at all. You are free to define what your scripts will do. Cagire includes a built-in audio engine called <a href="https://doux.livecoding.fr">Doux</a>. No external software is needed to make sound. It comes with oscillators, sample players, filters, reverb, delay, distortion, and more.</p>
|
<p>Cagire is a step sequencer where each step contains a Forth script instead of typical note data. When the sequencer reaches a step, it runs the associated script. Scripts can produce sound, trigger samples, apply effects, or do nothing at all. You are free to define what your scripts will do. Cagire includes a built-in audio engine called <a href="https://doux.livecoding.fr">Doux</a>. No external software is needed to make sound. It comes with oscillators, sample players, filters, reverb, delay, distortion, and more.</p>
|
||||||
|
|
||||||
<h2>Code Examples</h2>
|
<h2>Code Examples</h2>
|
||||||
|
<br>
|
||||||
<p>A minimal script that plays a middle C note using a sine wave:</p>
|
<p>A minimal script that plays a middle C note using a sine wave:</p>
|
||||||
<pre>c4 note sine sound .</pre>
|
<pre>c4 note sine sound .</pre>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<p>And now let's make it polyphonic and add different parameters per voice:</p>
|
||||||
|
<pre>c4 min7 note
|
||||||
|
sine sound
|
||||||
|
0.1 chorus
|
||||||
|
500 1000 1500 lpf
|
||||||
|
.</pre>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
|
||||||
<p>Sawtooth wave with lowpass filter, chorus and reverb:</p>
|
<p>Sawtooth wave with lowpass filter, chorus and reverb:</p>
|
||||||
<pre>saw sound 1200 lpf 0.2 chorus 0.8 verb .</pre>
|
<pre>saw sound 1200 lpf 0.2 chorus 0.8 verb .</pre>
|
||||||
|
<br>
|
||||||
|
|
||||||
<p>Pitched-down kick drum sample with distortion:</p>
|
<p>Pitched-down kick drum sample with distortion:</p>
|
||||||
<pre>kkick sound 1.5 distort 0.8 speed .</pre>
|
<pre>kkick sound 1.5 distort 0.8 speed .</pre>
|
||||||
|
<br>
|
||||||
|
|
||||||
<h2>Features</h2>
|
<h2>Features</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>32 banks × 32 patterns × 128 steps per project</li>
|
<li>Robust synthesis engine: synthesizers, sampling, effects, live input, and more to discover.</li>
|
||||||
<li>Ableton Link synchronization</li>
|
<li>Ableton Link: jam with your friends or include other software / hardware to your setup.</li>
|
||||||
<li>Built-in synthesis engine (oscillators, samples, wavetables)</li>
|
<li>32 banks × 32 patterns × 128 steps per project: (~131.000 scripts per project).</li>
|
||||||
<li>Effects: filters, reverb, delay, distortion, chorus</li>
|
<li>Forth: objectively the coolest / minimal / hackable language to make music with!</li>
|
||||||
<li>User-defined words and shared variables</li>
|
<li>Embedded dictionary and documentation!</li>
|
||||||
<li>Embedded dictionary and documentation</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Live Coding</h2>
|
<h2>Live Coding</h2>
|
||||||
@@ -209,10 +96,8 @@
|
|||||||
|
|
||||||
<h2>Credits</h2>
|
<h2>Credits</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="https://raphaelforment.fr">BuboBubo</a> (Raphaël Forment)</li>
|
<li><a href="https://raphaelforment.fr">BuboBubo</a> (Raphaël Maurice Forment).</li>
|
||||||
<li><a href="https://doux.livecoding.fr">Doux</a> audio engine, Rust port of Dough by <a href="https://eddyflux.cc/">Felix Roos</a></li>
|
<li>See <a href="https://doux.livecoding.fr">Doux</a> for engine credits.</li>
|
||||||
<li><a href="https://github.com/sourcebox/mi-plaits-dsp-rs">mi-plaits-dsp-rs</a> by Oliver Rockstedt, based on <a href="https://mutable-instruments.net/">Mutable Instruments</a> Plaits by Emilie Gillet</li>
|
|
||||||
<li>Related: <a href="https://strudel.cc">Strudel</a>, <a href="https://tidalcycles.org">TidalCycles</a>, <a href="https://sova.livecoding.fr">Sova</a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Links</h2>
|
<h2>Links</h2>
|
||||||
@@ -221,23 +106,8 @@
|
|||||||
<li><a href="https://ko-fi.com/raphaelbubo">Ko-fi</a></li>
|
<li><a href="https://ko-fi.com/raphaelbubo">Ko-fi</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p style="margin-top: 2rem; color: var(--text-muted);">AGPL-3.0 License</p>
|
<p style="margin-top: 2rem; color: var(--text-muted);">AGPL-3.0 License · <button id="theme-toggle" aria-label="Toggle theme">LIGHT</button></p>
|
||||||
|
|
||||||
<script>
|
<script is:inline src="/script.js"></script>
|
||||||
const toggle = document.getElementById('theme-toggle');
|
|
||||||
const root = document.documentElement;
|
|
||||||
|
|
||||||
if (localStorage.getItem('theme') === 'light') {
|
|
||||||
root.classList.add('light');
|
|
||||||
toggle.textContent = 'DARK';
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle.addEventListener('click', () => {
|
|
||||||
root.classList.toggle('light');
|
|
||||||
const isLight = root.classList.contains('light');
|
|
||||||
toggle.textContent = isLight ? 'DARK' : 'LIGHT';
|
|
||||||
localStorage.setItem('theme', isLight ? 'light' : 'dark');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user