Compare commits
20 Commits
730332cfb0
...
v0.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 985ab687d7 | |||
| 9b925d881e | |||
| 71146c7cea | |||
| 6b95f31afd | |||
| adee8d0d57 | |||
| f9c284effd | |||
| 57fd51be3e | |||
| ce70251057 | |||
| b47c789612 | |||
| dd853b8e1b | |||
| a0585b0814 | |||
| 2100b82dad | |||
| 15a4300db5 | |||
| fed39c01e8 | |||
| 0a4f1419eb | |||
| 793c83e18c | |||
| 20bc0ffcb4 | |||
| 8e09fd106e | |||
| 73ca0ff096 | |||
| 425f1c8627 |
41
.github/workflows/ci.yml
vendored
41
.github/workflows/ci.yml
vendored
@@ -45,7 +45,6 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
components: clippy
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -58,10 +57,13 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential cmake pkg-config libasound2-dev libclang-dev libjack-dev \
|
||||
libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libgl1-mesa-dev
|
||||
cargo install cargo-bundle
|
||||
|
||||
- name: Install dependencies (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: brew list cmake &>/dev/null || brew install cmake
|
||||
run: |
|
||||
brew list cmake &>/dev/null || brew install cmake
|
||||
cargo install cargo-bundle
|
||||
|
||||
- name: Install dependencies (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
@@ -75,11 +77,15 @@ jobs:
|
||||
- name: Build desktop
|
||||
run: cargo build --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --target ${{ matrix.target }}
|
||||
- name: Bundle desktop app
|
||||
if: runner.os != 'Windows'
|
||||
run: cargo bundle --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy --target ${{ matrix.target }} -- -D warnings
|
||||
- name: Zip macOS app bundle
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
cd target/${{ matrix.target }}/release/bundle/osx
|
||||
zip -r Cagire.app.zip Cagire.app
|
||||
|
||||
- name: Upload artifact (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
@@ -95,14 +101,21 @@ jobs:
|
||||
name: ${{ matrix.artifact }}
|
||||
path: target/${{ matrix.target }}/release/cagire.exe
|
||||
|
||||
- name: Upload desktop artifact (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
- name: Upload desktop artifact (Linux deb)
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}-desktop
|
||||
path: target/${{ matrix.target }}/release/cagire-desktop
|
||||
path: target/${{ matrix.target }}/release/bundle/deb/*.deb
|
||||
|
||||
- name: Upload desktop artifact (Windows)
|
||||
- name: Upload desktop artifact (macOS app bundle)
|
||||
if: runner.os == 'macOS'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}-desktop
|
||||
path: target/${{ matrix.target }}/release/bundle/osx/Cagire.app.zip
|
||||
|
||||
- name: Upload desktop artifact (Windows exe)
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -130,10 +143,12 @@ jobs:
|
||||
name=$(basename "$dir")
|
||||
if [[ "$name" == *-desktop ]]; then
|
||||
base="${name%-desktop}"
|
||||
if [ -f "$dir/cagire-desktop.exe" ]; then
|
||||
if ls "$dir"/*.deb 1>/dev/null 2>&1; then
|
||||
cp "$dir"/*.deb "release/${base}-desktop.deb"
|
||||
elif [ -f "$dir/Cagire.app.zip" ]; then
|
||||
cp "$dir/Cagire.app.zip" "release/${base}-desktop.app.zip"
|
||||
elif [ -f "$dir/cagire-desktop.exe" ]; then
|
||||
cp "$dir/cagire-desktop.exe" "release/${base}-desktop.exe"
|
||||
elif [ -f "$dir/cagire-desktop" ]; then
|
||||
cp "$dir/cagire-desktop" "release/${base}-desktop"
|
||||
fi
|
||||
else
|
||||
if [ -f "$dir/cagire.exe" ]; then
|
||||
|
||||
22
.github/workflows/pages.yml
vendored
22
.github/workflows/pages.yml
vendored
@@ -25,13 +25,33 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: website/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
working-directory: website
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
working-directory: website
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: website
|
||||
path: website/dist
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
|
||||
27
CHANGELOG.md
Normal file
27
CHANGELOG.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
- CI testing and codebase cleanup
|
||||
|
||||
## [0.0.1] - Initial Release
|
||||
- CI testing
|
||||
31
Cargo.toml
31
Cargo.toml
@@ -1,10 +1,24 @@
|
||||
[workspace]
|
||||
members = ["crates/forth", "crates/project", "crates/ratatui"]
|
||||
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.3"
|
||||
edition = "2021"
|
||||
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
|
||||
license = "AGPL-3.0"
|
||||
repository = "https://github.com/Bubobubobubobubo/cagire"
|
||||
homepage = "https://cagire.raphaelforment.fr"
|
||||
description = "Forth-based live coding music sequencer"
|
||||
|
||||
[package]
|
||||
name = "cagire"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "cagire"
|
||||
@@ -22,6 +36,7 @@ required-features = ["desktop"]
|
||||
[features]
|
||||
default = []
|
||||
desktop = [
|
||||
"cagire-forth/desktop",
|
||||
"egui",
|
||||
"eframe",
|
||||
"egui_ratatui",
|
||||
@@ -31,6 +46,7 @@ desktop = [
|
||||
|
||||
[dependencies]
|
||||
cagire-forth = { path = "crates/forth" }
|
||||
cagire-markdown = { path = "crates/markdown" }
|
||||
cagire-project = { path = "crates/project" }
|
||||
cagire-ratatui = { path = "crates/ratatui" }
|
||||
doux = { git = "https://github.com/sova-org/doux", features = ["native"] }
|
||||
@@ -51,6 +67,7 @@ rustfft = "6"
|
||||
thread-priority = "1"
|
||||
ringbuf = "0.4"
|
||||
arc-swap = "1"
|
||||
midir = "0.10"
|
||||
|
||||
# Desktop-only dependencies (behind feature flag)
|
||||
egui = { version = "0.33", optional = true }
|
||||
@@ -65,3 +82,11 @@ lto = "fat"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
[package.metadata.bundle.bin.cagire-desktop]
|
||||
name = "Cagire"
|
||||
identifier = "com.sova.cagire"
|
||||
icon = ["assets/Cagire.icns", "assets/Cagire.ico"]
|
||||
copyright = "Copyright (c) 2025 Raphaël Forment"
|
||||
category = "Music"
|
||||
short_description = "Forth-based music sequencer"
|
||||
|
||||
BIN
assets/Cagire.icns
Normal file
BIN
assets/Cagire.icns
Normal file
Binary file not shown.
BIN
assets/Cagire.ico
Normal file
BIN
assets/Cagire.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
@@ -1,7 +1,15 @@
|
||||
[package]
|
||||
name = "cagire-forth"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Forth virtual machine for cagire sequencer"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
desktop = []
|
||||
|
||||
[dependencies]
|
||||
rand = "0.8"
|
||||
|
||||
@@ -5,6 +5,8 @@ mod types;
|
||||
mod vm;
|
||||
mod words;
|
||||
|
||||
pub use types::{Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables};
|
||||
pub use types::{
|
||||
CcAccess, Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables,
|
||||
};
|
||||
pub use vm::Forth;
|
||||
pub use words::{Word, WordCompile, WORDS};
|
||||
|
||||
@@ -53,10 +53,11 @@ pub enum Op {
|
||||
Set,
|
||||
GetContext(String),
|
||||
Rand,
|
||||
ExpRand,
|
||||
LogRand,
|
||||
Seed,
|
||||
Cycle,
|
||||
PCycle,
|
||||
TCycle,
|
||||
Choose,
|
||||
ChanceExec,
|
||||
ProbExec,
|
||||
@@ -86,4 +87,12 @@ pub enum Op {
|
||||
Generate,
|
||||
GeomRange,
|
||||
Times,
|
||||
Chord(&'static [i64]),
|
||||
// MIDI
|
||||
MidiEmit,
|
||||
GetMidiCC,
|
||||
MidiClock,
|
||||
MidiStart,
|
||||
MidiStop,
|
||||
MidiContinue,
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
pub use scales::lookup;
|
||||
|
||||
@@ -4,6 +4,12 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::ops::Op;
|
||||
|
||||
/// Trait for accessing MIDI CC values. Implement this to provide CC memory to the Forth VM.
|
||||
pub trait CcAccess: Send + Sync {
|
||||
/// Get the CC value for a given device, channel (0-15), and CC number (0-127).
|
||||
fn get_cc(&self, device: usize, channel: usize, cc: usize) -> u8;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SourceSpan {
|
||||
pub start: usize,
|
||||
@@ -29,6 +35,13 @@ pub struct StepContext {
|
||||
pub speed: f64,
|
||||
pub fill: bool,
|
||||
pub nudge_secs: f64,
|
||||
pub cc_access: Option<Arc<dyn CcAccess>>,
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mouse_x: f64,
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mouse_y: f64,
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mouse_down: f64,
|
||||
}
|
||||
|
||||
impl StepContext {
|
||||
@@ -141,6 +154,14 @@ impl CmdRegister {
|
||||
&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<'_>> {
|
||||
if self.sound.is_some() || !self.params.is_empty() {
|
||||
Some((self.sound.as_ref(), self.params.as_slice()))
|
||||
@@ -154,4 +175,3 @@ impl CmdRegister {
|
||||
self.params.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,23 @@ impl Forth {
|
||||
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,
|
||||
emit_idx: usize,
|
||||
delta_secs: f64,
|
||||
@@ -363,38 +380,56 @@ impl Forth {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
Op::Emit => {
|
||||
let poly_count = compute_poly_count(cmd);
|
||||
let deltas = if cmd.deltas().is_empty() {
|
||||
vec![Value::Float(0.0, None)]
|
||||
} else {
|
||||
cmd.deltas().to_vec()
|
||||
};
|
||||
|
||||
for (emit_idx, delta_val) in deltas.iter().enumerate() {
|
||||
let delta_frac = delta_val.as_float()?;
|
||||
let delta_secs = ctx.nudge_secs + delta_frac * ctx.step_duration();
|
||||
// Record delta span for highlighting
|
||||
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() {
|
||||
for poly_idx in 0..poly_count {
|
||||
for delta_val in deltas.iter() {
|
||||
let delta_frac = delta_val.as_float()?;
|
||||
let delta_secs = ctx.nudge_secs + delta_frac * ctx.step_duration();
|
||||
if let Some(span) = delta_val.span() {
|
||||
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
|
||||
trace.selected_spans.push(span);
|
||||
}
|
||||
}
|
||||
if let Some(sound_val) =
|
||||
emit_with_cycling(cmd, poly_idx, delta_secs, outputs)?
|
||||
{
|
||||
if let Some(span) = sound_val.span() {
|
||||
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
|
||||
trace.selected_spans.push(span);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,6 +462,12 @@ impl Forth {
|
||||
"speed" => Value::Float(ctx.speed, None),
|
||||
"stepdur" => Value::Float(ctx.step_duration(), None),
|
||||
"fill" => Value::Int(if ctx.fill { 1 } else { 0 }, None),
|
||||
#[cfg(feature = "desktop")]
|
||||
"mx" => Value::Float(ctx.mouse_x, None),
|
||||
#[cfg(feature = "desktop")]
|
||||
"my" => Value::Float(ctx.mouse_y, None),
|
||||
#[cfg(feature = "desktop")]
|
||||
"mdown" => Value::Float(ctx.mouse_down, None),
|
||||
_ => Value::Int(0, None),
|
||||
};
|
||||
stack.push(val);
|
||||
@@ -449,11 +490,37 @@ impl Forth {
|
||||
let a_f = a.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 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
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 => {
|
||||
let s = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
*self.rng.lock().unwrap() = StdRng::seed_from_u64(s as u64);
|
||||
@@ -471,19 +538,6 @@ impl Forth {
|
||||
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 => {
|
||||
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
|
||||
if count == 0 {
|
||||
@@ -601,6 +655,13 @@ impl Forth {
|
||||
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 => {
|
||||
let shift = stack.pop().ok_or("stack underflow")?;
|
||||
let note = stack.pop().ok_or("stack underflow")?;
|
||||
@@ -653,27 +714,10 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::At => {
|
||||
let top = stack.pop().ok_or("stack underflow")?;
|
||||
let deltas = match &top {
|
||||
Value::Float(..) => vec![top],
|
||||
Value::Int(n, _) => {
|
||||
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()),
|
||||
};
|
||||
if stack.is_empty() {
|
||||
return Err("stack underflow".into());
|
||||
}
|
||||
let deltas = std::mem::take(stack);
|
||||
cmd.set_deltas(deltas);
|
||||
}
|
||||
|
||||
@@ -790,6 +834,87 @@ impl Forth {
|
||||
val *= ratio;
|
||||
}
|
||||
}
|
||||
|
||||
// MIDI operations
|
||||
Op::MidiEmit => {
|
||||
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
|
||||
let get_int = |name: &str| -> Option<i64> {
|
||||
params
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(k, _)| k == name)
|
||||
.and_then(|(_, v)| v.as_int().ok())
|
||||
};
|
||||
let get_float = |name: &str| -> Option<f64> {
|
||||
params
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(k, _)| k == name)
|
||||
.and_then(|(_, v)| v.as_float().ok())
|
||||
};
|
||||
let chan = get_int("chan")
|
||||
.map(|c| (c.clamp(1, 16) - 1) as u8)
|
||||
.unwrap_or(0);
|
||||
let dev = get_int("dev").map(|d| d.clamp(0, 3) as u8).unwrap_or(0);
|
||||
|
||||
if let (Some(cc), Some(val)) = (get_int("ccnum"), get_int("ccout")) {
|
||||
let cc = cc.clamp(0, 127) as u8;
|
||||
let val = val.clamp(0, 127) as u8;
|
||||
outputs.push(format!("/midi/cc/{cc}/{val}/chan/{chan}/dev/{dev}"));
|
||||
} else if let Some(bend) = get_float("bend") {
|
||||
let bend_clamped = bend.clamp(-1.0, 1.0);
|
||||
let bend_14bit = ((bend_clamped + 1.0) * 8191.5) as u16;
|
||||
outputs.push(format!("/midi/bend/{bend_14bit}/chan/{chan}/dev/{dev}"));
|
||||
} else if let Some(pressure) = get_int("pressure") {
|
||||
let pressure = pressure.clamp(0, 127) as u8;
|
||||
outputs.push(format!("/midi/pressure/{pressure}/chan/{chan}/dev/{dev}"));
|
||||
} else if let Some(program) = get_int("program") {
|
||||
let program = program.clamp(0, 127) as u8;
|
||||
outputs.push(format!("/midi/program/{program}/chan/{chan}/dev/{dev}"));
|
||||
} else {
|
||||
let note = get_int("note").unwrap_or(60).clamp(0, 127) as u8;
|
||||
let velocity = get_int("velocity").unwrap_or(100).clamp(0, 127) as u8;
|
||||
let dur = get_float("dur").unwrap_or(1.0);
|
||||
let dur_secs = dur * ctx.step_duration();
|
||||
outputs.push(format!(
|
||||
"/midi/note/{note}/vel/{velocity}/chan/{chan}/dur/{dur_secs}/dev/{dev}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Op::MidiClock => {
|
||||
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
|
||||
let dev = extract_dev_param(params);
|
||||
outputs.push(format!("/midi/clock/dev/{dev}"));
|
||||
}
|
||||
Op::MidiStart => {
|
||||
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
|
||||
let dev = extract_dev_param(params);
|
||||
outputs.push(format!("/midi/start/dev/{dev}"));
|
||||
}
|
||||
Op::MidiStop => {
|
||||
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
|
||||
let dev = extract_dev_param(params);
|
||||
outputs.push(format!("/midi/stop/dev/{dev}"));
|
||||
}
|
||||
Op::MidiContinue => {
|
||||
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
|
||||
let dev = extract_dev_param(params);
|
||||
outputs.push(format!("/midi/continue/dev/{dev}"));
|
||||
}
|
||||
Op::GetMidiCC => {
|
||||
let chan = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let cc = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
let cc_clamped = (cc.clamp(0, 127)) as usize;
|
||||
let chan_clamped = (chan.clamp(1, 16) - 1) as usize;
|
||||
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
|
||||
let dev = extract_dev_param(params) as usize;
|
||||
let val = ctx
|
||||
.cc_access
|
||||
.as_ref()
|
||||
.map(|cc| cc.get_cc(dev, chan_clamped, cc_clamped))
|
||||
.unwrap_or(0);
|
||||
stack.push(Value::Int(val as i64, None));
|
||||
}
|
||||
}
|
||||
pc += 1;
|
||||
}
|
||||
@@ -798,6 +923,16 @@ impl Forth {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_dev_param(params: &[(String, Value)]) -> u8 {
|
||||
params
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(k, _)| k == "dev")
|
||||
.and_then(|(_, v)| v.as_int().ok())
|
||||
.map(|d| d.clamp(0, 3) as u8)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn is_tempo_scaled_param(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use super::ops::Op;
|
||||
use super::theory;
|
||||
use super::types::{Dictionary, SourceSpan};
|
||||
@@ -463,17 +466,37 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "rand",
|
||||
aliases: &[],
|
||||
category: "Randomness",
|
||||
category: "Probability",
|
||||
stack: "(min max -- n|f)",
|
||||
desc: "Random in range. Int if both args are int, float otherwise",
|
||||
example: "1 6 rand => 4 | 0.0 1.0 rand => 0.42",
|
||||
compile: Simple,
|
||||
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 {
|
||||
name: "seed",
|
||||
aliases: &[],
|
||||
category: "Randomness",
|
||||
category: "Probability",
|
||||
stack: "(n --)",
|
||||
desc: "Set random seed",
|
||||
example: "12345 seed",
|
||||
@@ -483,7 +506,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "coin",
|
||||
aliases: &[],
|
||||
category: "Randomness",
|
||||
category: "Probability",
|
||||
stack: "(-- bool)",
|
||||
desc: "50/50 random boolean",
|
||||
example: "coin => 0 or 1",
|
||||
@@ -513,7 +536,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "choose",
|
||||
aliases: &[],
|
||||
category: "Randomness",
|
||||
category: "Probability",
|
||||
stack: "(..n n -- val)",
|
||||
desc: "Random pick from n items",
|
||||
example: "1 2 3 3 choose",
|
||||
@@ -523,7 +546,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "cycle",
|
||||
aliases: &[],
|
||||
category: "Selection",
|
||||
category: "Probability",
|
||||
stack: "(v1..vn n -- selected)",
|
||||
desc: "Cycle through n items by step runs",
|
||||
example: "60 64 67 3 cycle",
|
||||
@@ -533,23 +556,13 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "pcycle",
|
||||
aliases: &[],
|
||||
category: "Selection",
|
||||
category: "Probability",
|
||||
stack: "(v1..vn n -- selected)",
|
||||
desc: "Cycle through n items by pattern iteration",
|
||||
example: "60 64 67 3 pcycle",
|
||||
compile: Simple,
|
||||
varargs: true,
|
||||
},
|
||||
Word {
|
||||
name: "tcycle",
|
||||
aliases: &[],
|
||||
category: "Selection",
|
||||
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 {
|
||||
name: "every",
|
||||
aliases: &[],
|
||||
@@ -753,6 +766,39 @@ pub const WORDS: &[Word] = &[
|
||||
compile: Context("fill"),
|
||||
varargs: false,
|
||||
},
|
||||
#[cfg(feature = "desktop")]
|
||||
Word {
|
||||
name: "mx",
|
||||
aliases: &[],
|
||||
category: "Desktop",
|
||||
stack: "(-- x)",
|
||||
desc: "Normalized mouse X position (0-1)",
|
||||
example: "mx 440 880 range freq",
|
||||
compile: Context("mx"),
|
||||
varargs: false,
|
||||
},
|
||||
#[cfg(feature = "desktop")]
|
||||
Word {
|
||||
name: "my",
|
||||
aliases: &[],
|
||||
category: "Desktop",
|
||||
stack: "(-- y)",
|
||||
desc: "Normalized mouse Y position (0-1)",
|
||||
example: "my 0.1 0.9 range gain",
|
||||
compile: Context("my"),
|
||||
varargs: false,
|
||||
},
|
||||
#[cfg(feature = "desktop")]
|
||||
Word {
|
||||
name: "mdown",
|
||||
aliases: &[],
|
||||
category: "Desktop",
|
||||
stack: "(-- bool)",
|
||||
desc: "1 when mouse button held, 0 otherwise",
|
||||
example: "mdown { \"crash\" s . } ?",
|
||||
compile: Context("mdown"),
|
||||
varargs: false,
|
||||
},
|
||||
// Music
|
||||
Word {
|
||||
name: "mtof",
|
||||
@@ -774,6 +820,292 @@ pub const WORDS: &[Word] = &[
|
||||
compile: Simple,
|
||||
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
|
||||
Word {
|
||||
name: "ramp",
|
||||
@@ -889,9 +1221,9 @@ pub const WORDS: &[Word] = &[
|
||||
name: "at",
|
||||
aliases: &[],
|
||||
category: "Time",
|
||||
stack: "(v1..vn n --)",
|
||||
stack: "(v1..vn --)",
|
||||
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,
|
||||
varargs: true,
|
||||
},
|
||||
@@ -1150,7 +1482,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "gain",
|
||||
aliases: &[],
|
||||
category: "Gain",
|
||||
category: "Envelope",
|
||||
stack: "(f --)",
|
||||
desc: "Set volume (0-1)",
|
||||
example: "0.8 gain",
|
||||
@@ -1160,7 +1492,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "postgain",
|
||||
aliases: &[],
|
||||
category: "Gain",
|
||||
category: "Envelope",
|
||||
stack: "(f --)",
|
||||
desc: "Set post gain",
|
||||
example: "1.2 postgain",
|
||||
@@ -1170,7 +1502,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "velocity",
|
||||
aliases: &[],
|
||||
category: "Gain",
|
||||
category: "Envelope",
|
||||
stack: "(f --)",
|
||||
desc: "Set velocity",
|
||||
example: "100 velocity",
|
||||
@@ -1180,7 +1512,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "pan",
|
||||
aliases: &[],
|
||||
category: "Gain",
|
||||
category: "Stereo",
|
||||
stack: "(f --)",
|
||||
desc: "Set pan (-1 to 1)",
|
||||
example: "0.5 pan",
|
||||
@@ -1460,7 +1792,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "llpf",
|
||||
aliases: &[],
|
||||
category: "Ladder Filter",
|
||||
category: "Filter",
|
||||
stack: "(f --)",
|
||||
desc: "Set ladder lowpass frequency",
|
||||
example: "2000 llpf",
|
||||
@@ -1470,7 +1802,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "llpq",
|
||||
aliases: &[],
|
||||
category: "Ladder Filter",
|
||||
category: "Filter",
|
||||
stack: "(f --)",
|
||||
desc: "Set ladder lowpass resonance",
|
||||
example: "0.5 llpq",
|
||||
@@ -1480,7 +1812,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "lhpf",
|
||||
aliases: &[],
|
||||
category: "Ladder Filter",
|
||||
category: "Filter",
|
||||
stack: "(f --)",
|
||||
desc: "Set ladder highpass frequency",
|
||||
example: "100 lhpf",
|
||||
@@ -1490,7 +1822,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "lhpq",
|
||||
aliases: &[],
|
||||
category: "Ladder Filter",
|
||||
category: "Filter",
|
||||
stack: "(f --)",
|
||||
desc: "Set ladder highpass resonance",
|
||||
example: "0.5 lhpq",
|
||||
@@ -1500,7 +1832,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "lbpf",
|
||||
aliases: &[],
|
||||
category: "Ladder Filter",
|
||||
category: "Filter",
|
||||
stack: "(f --)",
|
||||
desc: "Set ladder bandpass frequency",
|
||||
example: "1000 lbpf",
|
||||
@@ -1510,7 +1842,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "lbpq",
|
||||
aliases: &[],
|
||||
category: "Ladder Filter",
|
||||
category: "Filter",
|
||||
stack: "(f --)",
|
||||
desc: "Set ladder bandpass resonance",
|
||||
example: "0.5 lbpq",
|
||||
@@ -1530,7 +1862,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "penv",
|
||||
aliases: &[],
|
||||
category: "Pitch Env",
|
||||
category: "Envelope",
|
||||
stack: "(f --)",
|
||||
desc: "Set pitch envelope",
|
||||
example: "0.5 penv",
|
||||
@@ -1540,7 +1872,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "patt",
|
||||
aliases: &[],
|
||||
category: "Pitch Env",
|
||||
category: "Envelope",
|
||||
stack: "(f --)",
|
||||
desc: "Set pitch attack",
|
||||
example: "0.01 patt",
|
||||
@@ -1550,7 +1882,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "pdec",
|
||||
aliases: &[],
|
||||
category: "Pitch Env",
|
||||
category: "Envelope",
|
||||
stack: "(f --)",
|
||||
desc: "Set pitch decay",
|
||||
example: "0.1 pdec",
|
||||
@@ -1560,7 +1892,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "psus",
|
||||
aliases: &[],
|
||||
category: "Pitch Env",
|
||||
category: "Envelope",
|
||||
stack: "(f --)",
|
||||
desc: "Set pitch sustain",
|
||||
example: "0 psus",
|
||||
@@ -1570,7 +1902,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "prel",
|
||||
aliases: &[],
|
||||
category: "Pitch Env",
|
||||
category: "Envelope",
|
||||
stack: "(f --)",
|
||||
desc: "Set pitch release",
|
||||
example: "0.1 prel",
|
||||
@@ -1610,7 +1942,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "fm",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
desc: "Set FM frequency",
|
||||
example: "200 fm",
|
||||
@@ -1620,7 +1952,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "fmh",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
desc: "Set FM harmonic ratio",
|
||||
example: "2 fmh",
|
||||
@@ -1630,7 +1962,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "fmshape",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
desc: "Set FM shape",
|
||||
example: "0 fmshape",
|
||||
@@ -1640,7 +1972,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "fme",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
desc: "Set FM envelope",
|
||||
example: "0.5 fme",
|
||||
@@ -1650,7 +1982,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "fma",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
desc: "Set FM attack",
|
||||
example: "0.01 fma",
|
||||
@@ -1660,7 +1992,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "fmd",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
desc: "Set FM decay",
|
||||
example: "0.1 fmd",
|
||||
@@ -1670,7 +2002,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "fms",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
desc: "Set FM sustain",
|
||||
example: "0.5 fms",
|
||||
@@ -1680,7 +2012,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "fmr",
|
||||
aliases: &[],
|
||||
category: "Modulation",
|
||||
category: "FM",
|
||||
stack: "(f --)",
|
||||
desc: "Set FM release",
|
||||
example: "0.1 fmr",
|
||||
@@ -1850,7 +2182,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "eqlo",
|
||||
aliases: &[],
|
||||
category: "EQ",
|
||||
category: "Filter",
|
||||
stack: "(f --)",
|
||||
desc: "Set low shelf gain (dB)",
|
||||
example: "3 eqlo",
|
||||
@@ -1860,7 +2192,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "eqmid",
|
||||
aliases: &[],
|
||||
category: "EQ",
|
||||
category: "Filter",
|
||||
stack: "(f --)",
|
||||
desc: "Set mid peak gain (dB)",
|
||||
example: "-2 eqmid",
|
||||
@@ -1870,7 +2202,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "eqhi",
|
||||
aliases: &[],
|
||||
category: "EQ",
|
||||
category: "Filter",
|
||||
stack: "(f --)",
|
||||
desc: "Set high shelf gain (dB)",
|
||||
example: "1 eqhi",
|
||||
@@ -1880,7 +2212,7 @@ pub const WORDS: &[Word] = &[
|
||||
Word {
|
||||
name: "tilt",
|
||||
aliases: &[],
|
||||
category: "EQ",
|
||||
category: "Filter",
|
||||
stack: "(f --)",
|
||||
desc: "Set tilt EQ (-1 dark, 1 bright)",
|
||||
example: "-0.5 tilt",
|
||||
@@ -2280,8 +2612,154 @@ pub const WORDS: &[Word] = &[
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
// MIDI
|
||||
Word {
|
||||
name: "chan",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(n --)",
|
||||
desc: "Set MIDI channel 1-16",
|
||||
example: "1 chan",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "ccnum",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(n --)",
|
||||
desc: "Set MIDI CC number 0-127",
|
||||
example: "1 ccnum",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "ccout",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(n --)",
|
||||
desc: "Set MIDI CC output value 0-127",
|
||||
example: "64 ccout",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "bend",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(f --)",
|
||||
desc: "Set pitch bend -1.0 to 1.0 (0 = center)",
|
||||
example: "0.5 bend",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "pressure",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(n --)",
|
||||
desc: "Set channel pressure (aftertouch) 0-127",
|
||||
example: "64 pressure",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "program",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(n --)",
|
||||
desc: "Set program change number 0-127",
|
||||
example: "0 program",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "m.",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(--)",
|
||||
desc: "Emit MIDI message from params (note/cc/bend/pressure/program)",
|
||||
example: "60 note 100 velocity 1 chan m.",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "mclock",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(--)",
|
||||
desc: "Send MIDI clock pulse (24 per quarter note)",
|
||||
example: "mclock",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "mstart",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(--)",
|
||||
desc: "Send MIDI start message",
|
||||
example: "mstart",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "mstop",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(--)",
|
||||
desc: "Send MIDI stop message",
|
||||
example: "mstop",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "mcont",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(--)",
|
||||
desc: "Send MIDI continue message",
|
||||
example: "mcont",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "ccval",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(cc chan -- val)",
|
||||
desc: "Read CC value 0-127 from MIDI input (uses dev param for device)",
|
||||
example: "1 1 ccval",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
Word {
|
||||
name: "dev",
|
||||
aliases: &[],
|
||||
category: "MIDI",
|
||||
stack: "(n --)",
|
||||
desc: "Set MIDI device slot 0-3 for output/input",
|
||||
example: "1 dev 60 note m.",
|
||||
compile: Param,
|
||||
varargs: false,
|
||||
},
|
||||
];
|
||||
|
||||
static WORD_MAP: LazyLock<HashMap<&'static str, &'static Word>> = LazyLock::new(|| {
|
||||
let mut map = HashMap::with_capacity(WORDS.len() * 2);
|
||||
for word in WORDS {
|
||||
map.insert(word.name, word);
|
||||
for alias in word.aliases {
|
||||
map.insert(alias, word);
|
||||
}
|
||||
}
|
||||
map
|
||||
});
|
||||
|
||||
fn lookup_word(name: &str) -> Option<&'static Word> {
|
||||
WORD_MAP.get(name).copied()
|
||||
}
|
||||
|
||||
pub(super) fn simple_op(name: &str) -> Option<Op> {
|
||||
Some(match name {
|
||||
"dup" => Op::Dup,
|
||||
@@ -2326,10 +2804,11 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
|
||||
"sound" => Op::NewCmd,
|
||||
"." => Op::Emit,
|
||||
"rand" => Op::Rand,
|
||||
"exprand" => Op::ExpRand,
|
||||
"logrand" => Op::LogRand,
|
||||
"seed" => Op::Seed,
|
||||
"cycle" => Op::Cycle,
|
||||
"pcycle" => Op::PCycle,
|
||||
"tcycle" => Op::TCycle,
|
||||
"choose" => Op::Choose,
|
||||
"every" => Op::Every,
|
||||
"chance" => Op::ChanceExec,
|
||||
@@ -2357,6 +2836,12 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
|
||||
"gen" => Op::Generate,
|
||||
"geom.." => Op::GeomRange,
|
||||
"times" => Op::Times,
|
||||
"m." => Op::MidiEmit,
|
||||
"ccval" => Op::GetMidiCC,
|
||||
"mclock" => Op::MidiClock,
|
||||
"mstart" => Op::MidiStart,
|
||||
"mstop" => Op::MidiStop,
|
||||
"mcont" => Op::MidiContinue,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -2466,23 +2951,26 @@ pub(super) fn compile_word(
|
||||
return true;
|
||||
}
|
||||
|
||||
for word in WORDS {
|
||||
if word.name == name || word.aliases.contains(&name) {
|
||||
match &word.compile {
|
||||
Simple => {
|
||||
if let Some(op) = simple_op(word.name) {
|
||||
ops.push(op);
|
||||
}
|
||||
}
|
||||
Context(ctx) => ops.push(Op::GetContext((*ctx).into())),
|
||||
Param => ops.push(Op::SetParam(word.name.into())),
|
||||
Probability(p) => {
|
||||
ops.push(Op::PushFloat(*p, None));
|
||||
ops.push(Op::ChanceExec);
|
||||
if let Some(intervals) = theory::chords::lookup(name) {
|
||||
ops.push(Op::Chord(intervals));
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(word) = lookup_word(name) {
|
||||
match &word.compile {
|
||||
Simple => {
|
||||
if let Some(op) = simple_op(word.name) {
|
||||
ops.push(op);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
Context(ctx) => ops.push(Op::GetContext((*ctx).into())),
|
||||
Param => ops.push(Op::SetParam(word.name.into())),
|
||||
Probability(p) => {
|
||||
ops.push(Op::PushFloat(*p, None));
|
||||
ops.push(Op::ChanceExec);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// @varname - fetch variable
|
||||
|
||||
12
crates/markdown/Cargo.toml
Normal file
12
crates/markdown/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "cagire-markdown"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Markdown rendering for cagire sequencer"
|
||||
|
||||
[dependencies]
|
||||
minimad = "0.13"
|
||||
ratatui = "0.30"
|
||||
13
crates/markdown/src/highlighter.rs
Normal file
13
crates/markdown/src/highlighter.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use ratatui::style::Style;
|
||||
|
||||
pub trait CodeHighlighter {
|
||||
fn highlight(&self, line: &str) -> Vec<(Style, String)>;
|
||||
}
|
||||
|
||||
pub struct NoHighlight;
|
||||
|
||||
impl CodeHighlighter for NoHighlight {
|
||||
fn highlight(&self, line: &str) -> Vec<(Style, String)> {
|
||||
vec![(Style::default(), line.to_string())]
|
||||
}
|
||||
}
|
||||
7
crates/markdown/src/lib.rs
Normal file
7
crates/markdown/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod highlighter;
|
||||
mod parser;
|
||||
mod theme;
|
||||
|
||||
pub use highlighter::{CodeHighlighter, NoHighlight};
|
||||
pub use parser::parse;
|
||||
pub use theme::{DefaultTheme, MarkdownTheme};
|
||||
327
crates/markdown/src/parser.rs
Normal file
327
crates/markdown/src/parser.rs
Normal file
@@ -0,0 +1,327 @@
|
||||
use minimad::{Composite, CompositeStyle, Compound, Line, TableRow};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line as RLine, Span};
|
||||
|
||||
use crate::highlighter::CodeHighlighter;
|
||||
use crate::theme::MarkdownTheme;
|
||||
|
||||
pub fn parse<T: MarkdownTheme, H: CodeHighlighter>(
|
||||
md: &str,
|
||||
theme: &T,
|
||||
highlighter: &H,
|
||||
) -> Vec<RLine<'static>> {
|
||||
let processed = preprocess_markdown(md);
|
||||
let text = minimad::Text::from(processed.as_str());
|
||||
let mut lines = Vec::new();
|
||||
|
||||
let mut code_line_nr: usize = 0;
|
||||
let mut table_buffer: Vec<TableRow> = Vec::new();
|
||||
|
||||
let flush_table = |buf: &mut Vec<TableRow>, out: &mut Vec<RLine<'static>>, theme: &T| {
|
||||
if buf.is_empty() {
|
||||
return;
|
||||
}
|
||||
let col_widths = compute_column_widths(buf);
|
||||
for (row_idx, row) in buf.drain(..).enumerate() {
|
||||
out.push(render_table_row(row, row_idx, &col_widths, theme));
|
||||
}
|
||||
};
|
||||
|
||||
for line in text.lines {
|
||||
match line {
|
||||
Line::Normal(composite) if composite.style == CompositeStyle::Code => {
|
||||
flush_table(&mut table_buffer, &mut lines, theme);
|
||||
code_line_nr += 1;
|
||||
let raw: String = composite
|
||||
.compounds
|
||||
.iter()
|
||||
.map(|c: &minimad::Compound| c.src)
|
||||
.collect();
|
||||
let mut spans = vec![
|
||||
Span::styled(format!(" {code_line_nr:>2} "), theme.code_border()),
|
||||
Span::styled("│ ", theme.code_border()),
|
||||
];
|
||||
spans.extend(
|
||||
highlighter
|
||||
.highlight(&raw)
|
||||
.into_iter()
|
||||
.map(|(style, text)| Span::styled(text, style)),
|
||||
);
|
||||
lines.push(RLine::from(spans));
|
||||
}
|
||||
Line::Normal(composite) => {
|
||||
flush_table(&mut table_buffer, &mut lines, theme);
|
||||
code_line_nr = 0;
|
||||
lines.push(composite_to_line(composite, theme));
|
||||
}
|
||||
Line::TableRow(row) => {
|
||||
code_line_nr = 0;
|
||||
table_buffer.push(row);
|
||||
}
|
||||
Line::TableRule(_) => {}
|
||||
_ => {
|
||||
flush_table(&mut table_buffer, &mut lines, theme);
|
||||
code_line_nr = 0;
|
||||
lines.push(RLine::from(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
flush_table(&mut table_buffer, &mut lines, theme);
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
pub fn preprocess_markdown(md: &str) -> String {
|
||||
let mut out = String::with_capacity(md.len());
|
||||
for line in md.lines() {
|
||||
let line = convert_dash_lists(line);
|
||||
let mut result = String::with_capacity(line.len());
|
||||
let mut chars = line.char_indices().peekable();
|
||||
let bytes = line.as_bytes();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
if c == '`' {
|
||||
result.push(c);
|
||||
for (_, ch) in chars.by_ref() {
|
||||
result.push(ch);
|
||||
if ch == '`' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == '_' {
|
||||
let before_is_space = i == 0 || bytes[i - 1] == b' ';
|
||||
if before_is_space {
|
||||
if let Some(end) = line[i + 1..].find('_') {
|
||||
let inner = &line[i + 1..i + 1 + end];
|
||||
if !inner.is_empty() {
|
||||
result.push('*');
|
||||
result.push_str(inner);
|
||||
result.push('*');
|
||||
for _ in 0..end {
|
||||
chars.next();
|
||||
}
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push(c);
|
||||
}
|
||||
out.push_str(&result);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn convert_dash_lists(line: &str) -> String {
|
||||
let trimmed = line.trim_start();
|
||||
if let Some(rest) = trimmed.strip_prefix("- ") {
|
||||
let indent = line.len() - trimmed.len();
|
||||
format!("{}* {}", " ".repeat(indent), rest)
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_text_width(cell: &Composite) -> usize {
|
||||
cell.compounds.iter().map(|c| c.src.chars().count()).sum()
|
||||
}
|
||||
|
||||
fn compute_column_widths(rows: &[TableRow]) -> Vec<usize> {
|
||||
let mut widths: Vec<usize> = Vec::new();
|
||||
for row in rows {
|
||||
for (i, cell) in row.cells.iter().enumerate() {
|
||||
let w = cell_text_width(cell);
|
||||
if i >= widths.len() {
|
||||
widths.push(w);
|
||||
} else if w > widths[i] {
|
||||
widths[i] = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
widths
|
||||
}
|
||||
|
||||
fn render_table_row<T: MarkdownTheme>(
|
||||
row: TableRow,
|
||||
row_idx: usize,
|
||||
col_widths: &[usize],
|
||||
theme: &T,
|
||||
) -> RLine<'static> {
|
||||
let is_header = row_idx == 0;
|
||||
let bg = if is_header {
|
||||
theme.table_header_bg()
|
||||
} else if row_idx.is_multiple_of(2) {
|
||||
theme.table_row_even()
|
||||
} else {
|
||||
theme.table_row_odd()
|
||||
};
|
||||
|
||||
let base_style = if is_header {
|
||||
theme.text().bg(bg).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
theme.text().bg(bg)
|
||||
};
|
||||
|
||||
let sep_style = theme.code_border().bg(bg);
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
|
||||
for (i, cell) in row.cells.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
spans.push(Span::styled(" │ ", sep_style));
|
||||
}
|
||||
let target_width = col_widths.get(i).copied().unwrap_or(0);
|
||||
let cell_width = cell
|
||||
.compounds
|
||||
.iter()
|
||||
.map(|c| c.src.chars().count())
|
||||
.sum::<usize>();
|
||||
|
||||
for compound in cell.compounds {
|
||||
compound_to_spans(compound, base_style, &mut spans, theme);
|
||||
}
|
||||
|
||||
let padding = target_width.saturating_sub(cell_width);
|
||||
if padding > 0 {
|
||||
spans.push(Span::styled(" ".repeat(padding), base_style));
|
||||
}
|
||||
}
|
||||
|
||||
RLine::from(spans)
|
||||
}
|
||||
|
||||
fn composite_to_line<T: MarkdownTheme>(composite: Composite, theme: &T) -> RLine<'static> {
|
||||
let base_style = match composite.style {
|
||||
CompositeStyle::Header(1) => theme.h1(),
|
||||
CompositeStyle::Header(2) => theme.h2(),
|
||||
CompositeStyle::Header(_) => theme.h3(),
|
||||
CompositeStyle::ListItem(_) => theme.list(),
|
||||
CompositeStyle::Quote => theme.quote(),
|
||||
CompositeStyle::Code => theme.code(),
|
||||
CompositeStyle::Paragraph => theme.text(),
|
||||
};
|
||||
|
||||
let prefix: String = match composite.style {
|
||||
CompositeStyle::ListItem(depth) => {
|
||||
let indent = " ".repeat(depth as usize);
|
||||
format!("{indent}• ")
|
||||
}
|
||||
CompositeStyle::Quote => " │ ".to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
if !prefix.is_empty() {
|
||||
spans.push(Span::styled(prefix, base_style));
|
||||
}
|
||||
|
||||
for compound in composite.compounds {
|
||||
compound_to_spans(compound, base_style, &mut spans, theme);
|
||||
}
|
||||
|
||||
RLine::from(spans)
|
||||
}
|
||||
|
||||
fn compound_to_spans<T: MarkdownTheme>(
|
||||
compound: Compound,
|
||||
base: Style,
|
||||
out: &mut Vec<Span<'static>>,
|
||||
theme: &T,
|
||||
) {
|
||||
let mut style = base;
|
||||
|
||||
if compound.bold {
|
||||
style = style.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
if compound.italic {
|
||||
style = style.add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
if compound.code {
|
||||
style = theme.code();
|
||||
}
|
||||
if compound.strikeout {
|
||||
style = style.add_modifier(Modifier::CROSSED_OUT);
|
||||
}
|
||||
|
||||
let src = compound.src.to_string();
|
||||
let link_style = theme.link();
|
||||
|
||||
let mut rest = src.as_str();
|
||||
while let Some(start) = rest.find('[') {
|
||||
let after_bracket = &rest[start + 1..];
|
||||
if let Some(text_end) = after_bracket.find("](") {
|
||||
let url_start = start + 1 + text_end + 2;
|
||||
if let Some(url_end) = rest[url_start..].find(')') {
|
||||
if start > 0 {
|
||||
out.push(Span::styled(rest[..start].to_string(), style));
|
||||
}
|
||||
let text = &rest[start + 1..start + 1 + text_end];
|
||||
let url = &rest[url_start..url_start + url_end];
|
||||
if text == url {
|
||||
out.push(Span::styled(url.to_string(), link_style));
|
||||
} else {
|
||||
out.push(Span::styled(text.to_string(), link_style));
|
||||
out.push(Span::styled(format!(" ({url})"), theme.link_url()));
|
||||
}
|
||||
rest = &rest[url_start + url_end + 1..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(Span::styled(rest[..start + 1].to_string(), style));
|
||||
rest = &rest[start + 1..];
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
out.push(Span::styled(rest.to_string(), style));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::highlighter::NoHighlight;
|
||||
use crate::theme::DefaultTheme;
|
||||
|
||||
#[test]
|
||||
fn test_preprocess_underscores() {
|
||||
assert_eq!(preprocess_markdown("_italic_"), "*italic*\n");
|
||||
assert_eq!(preprocess_markdown("word_with_underscores"), "word_with_underscores\n");
|
||||
assert_eq!(preprocess_markdown("hello _world_"), "hello *world*\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preprocess_dash_lists() {
|
||||
assert_eq!(convert_dash_lists("- item"), "* item");
|
||||
assert_eq!(convert_dash_lists(" - nested"), " * nested");
|
||||
assert_eq!(convert_dash_lists("not-a-list"), "not-a-list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_headings() {
|
||||
let md = "# H1\n## H2\n### H3";
|
||||
let lines = parse(md, &DefaultTheme, &NoHighlight);
|
||||
assert_eq!(lines.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_code_block() {
|
||||
let md = "```\ncode line\n```";
|
||||
let lines = parse(md, &DefaultTheme, &NoHighlight);
|
||||
assert!(!lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_table() {
|
||||
let md = "| A | B |\n|---|---|\n| 1 | 2 |";
|
||||
let lines = parse(md, &DefaultTheme, &NoHighlight);
|
||||
assert_eq!(lines.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_theme_works() {
|
||||
let md = "Hello **world**";
|
||||
let lines = parse(md, &DefaultTheme, &NoHighlight);
|
||||
assert_eq!(lines.len(), 1);
|
||||
}
|
||||
}
|
||||
77
crates/markdown/src/theme.rs
Normal file
77
crates/markdown/src/theme.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
||||
pub trait MarkdownTheme {
|
||||
fn h1(&self) -> Style;
|
||||
fn h2(&self) -> Style;
|
||||
fn h3(&self) -> Style;
|
||||
fn text(&self) -> Style;
|
||||
fn code(&self) -> Style;
|
||||
fn code_border(&self) -> Style;
|
||||
fn link(&self) -> Style;
|
||||
fn link_url(&self) -> Style;
|
||||
fn quote(&self) -> Style;
|
||||
fn list(&self) -> Style;
|
||||
fn table_header_bg(&self) -> Color;
|
||||
fn table_row_even(&self) -> Color;
|
||||
fn table_row_odd(&self) -> Color;
|
||||
}
|
||||
|
||||
pub struct DefaultTheme;
|
||||
|
||||
impl MarkdownTheme for DefaultTheme {
|
||||
fn h1(&self) -> Style {
|
||||
Style::new()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
|
||||
}
|
||||
|
||||
fn h2(&self) -> Style {
|
||||
Style::new().fg(Color::Blue).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
fn h3(&self) -> Style {
|
||||
Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
fn text(&self) -> Style {
|
||||
Style::new().fg(Color::White)
|
||||
}
|
||||
|
||||
fn code(&self) -> Style {
|
||||
Style::new().fg(Color::Yellow)
|
||||
}
|
||||
|
||||
fn code_border(&self) -> Style {
|
||||
Style::new().fg(Color::DarkGray)
|
||||
}
|
||||
|
||||
fn link(&self) -> Style {
|
||||
Style::new()
|
||||
.fg(Color::Blue)
|
||||
.add_modifier(Modifier::UNDERLINED)
|
||||
}
|
||||
|
||||
fn link_url(&self) -> Style {
|
||||
Style::new().fg(Color::DarkGray)
|
||||
}
|
||||
|
||||
fn quote(&self) -> Style {
|
||||
Style::new().fg(Color::Gray)
|
||||
}
|
||||
|
||||
fn list(&self) -> Style {
|
||||
Style::new().fg(Color::White)
|
||||
}
|
||||
|
||||
fn table_header_bg(&self) -> Color {
|
||||
Color::DarkGray
|
||||
}
|
||||
|
||||
fn table_row_even(&self) -> Color {
|
||||
Color::Reset
|
||||
}
|
||||
|
||||
fn table_row_odd(&self) -> Color {
|
||||
Color::Reset
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
[package]
|
||||
name = "cagire-project"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Project data structures for cagire sequencer"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -25,6 +25,8 @@ struct ProjectFile {
|
||||
sample_paths: Vec<PathBuf>,
|
||||
#[serde(default = "default_tempo")]
|
||||
tempo: f64,
|
||||
#[serde(default)]
|
||||
playing_patterns: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
fn default_tempo() -> f64 {
|
||||
@@ -38,6 +40,7 @@ impl From<&Project> for ProjectFile {
|
||||
banks: project.banks.clone(),
|
||||
sample_paths: project.sample_paths.clone(),
|
||||
tempo: project.tempo,
|
||||
playing_patterns: project.playing_patterns.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +51,7 @@ impl From<ProjectFile> for Project {
|
||||
banks: file.banks,
|
||||
sample_paths: file.sample_paths,
|
||||
tempo: file.tempo,
|
||||
playing_patterns: file.playing_patterns,
|
||||
};
|
||||
project.normalize();
|
||||
project
|
||||
|
||||
@@ -450,6 +450,8 @@ pub struct Project {
|
||||
pub sample_paths: Vec<PathBuf>,
|
||||
#[serde(default = "default_tempo")]
|
||||
pub tempo: f64,
|
||||
#[serde(default)]
|
||||
pub playing_patterns: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
fn default_tempo() -> f64 {
|
||||
@@ -462,6 +464,7 @@ impl Default for Project {
|
||||
banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(),
|
||||
sample_paths: Vec::new(),
|
||||
tempo: default_tempo(),
|
||||
playing_patterns: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
[package]
|
||||
name = "cagire-ratatui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "TUI components for cagire sequencer"
|
||||
|
||||
[dependencies]
|
||||
rand = "0.8"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::theme::{browser, input, ui};
|
||||
use ratatui::style::Color;
|
||||
use crate::theme;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::Frame;
|
||||
@@ -14,7 +13,7 @@ pub struct FileBrowserModal<'a> {
|
||||
entries: &'a [(String, bool, bool)],
|
||||
selected: usize,
|
||||
scroll_offset: usize,
|
||||
border_color: Color,
|
||||
border_color: Option<Color>,
|
||||
width: u16,
|
||||
height: u16,
|
||||
}
|
||||
@@ -27,7 +26,7 @@ impl<'a> FileBrowserModal<'a> {
|
||||
entries,
|
||||
selected: 0,
|
||||
scroll_offset: 0,
|
||||
border_color: ui::TEXT_PRIMARY,
|
||||
border_color: None,
|
||||
width: 60,
|
||||
height: 16,
|
||||
}
|
||||
@@ -44,7 +43,7 @@ impl<'a> FileBrowserModal<'a> {
|
||||
}
|
||||
|
||||
pub fn border_color(mut self, c: Color) -> Self {
|
||||
self.border_color = c;
|
||||
self.border_color = Some(c);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -59,10 +58,13 @@ impl<'a> FileBrowserModal<'a> {
|
||||
}
|
||||
|
||||
pub fn render_centered(self, frame: &mut Frame, term: Rect) {
|
||||
let colors = theme::get();
|
||||
let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
|
||||
|
||||
let inner = ModalFrame::new(self.title)
|
||||
.width(self.width)
|
||||
.height(self.height)
|
||||
.border_color(self.border_color)
|
||||
.border_color(border_color)
|
||||
.render_centered(frame, term);
|
||||
|
||||
let rows = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner);
|
||||
@@ -71,8 +73,8 @@ impl<'a> FileBrowserModal<'a> {
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw("> "),
|
||||
Span::styled(self.input, Style::new().fg(input::TEXT)),
|
||||
Span::styled("█", Style::new().fg(input::CURSOR)),
|
||||
Span::styled(self.input, Style::new().fg(colors.input.text)),
|
||||
Span::styled("█", Style::new().fg(colors.input.cursor)),
|
||||
])),
|
||||
rows[0],
|
||||
);
|
||||
@@ -97,13 +99,13 @@ impl<'a> FileBrowserModal<'a> {
|
||||
format!("{prefix}{name}")
|
||||
};
|
||||
let color = if is_selected {
|
||||
browser::SELECTED
|
||||
colors.browser.selected
|
||||
} else if *is_dir {
|
||||
browser::DIRECTORY
|
||||
colors.browser.directory
|
||||
} else if *is_cagire {
|
||||
browser::PROJECT_FILE
|
||||
colors.browser.project_file
|
||||
} else {
|
||||
browser::FILE
|
||||
colors.browser.file
|
||||
};
|
||||
Line::from(Span::styled(display, Style::new().fg(color)))
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::theme::{hint, ui};
|
||||
use crate::theme;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
@@ -51,10 +51,11 @@ impl<'a> ListSelect<'a> {
|
||||
}
|
||||
|
||||
pub fn render(self, frame: &mut Frame, area: Rect) {
|
||||
let cursor_style = Style::new().fg(hint::KEY).add_modifier(Modifier::BOLD);
|
||||
let selected_style = Style::new().fg(ui::ACCENT);
|
||||
let colors = theme::get();
|
||||
let cursor_style = Style::new().fg(colors.hint.key).add_modifier(Modifier::BOLD);
|
||||
let selected_style = Style::new().fg(colors.ui.accent);
|
||||
let normal_style = Style::default();
|
||||
let indicator_style = Style::new().fg(ui::TEXT_DIM);
|
||||
let indicator_style = Style::new().fg(colors.ui.text_dim);
|
||||
|
||||
let visible_end = (self.scroll_offset + self.visible_count).min(self.items.len());
|
||||
let has_above = self.scroll_offset > 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::theme::{browser, search};
|
||||
use crate::theme;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
@@ -59,10 +59,11 @@ impl<'a> SampleBrowser<'a> {
|
||||
}
|
||||
|
||||
pub fn render(self, frame: &mut Frame, area: Rect) {
|
||||
let colors = theme::get();
|
||||
let border_style = if self.focused {
|
||||
Style::new().fg(browser::FOCUSED_BORDER)
|
||||
Style::new().fg(colors.browser.focused_border)
|
||||
} else {
|
||||
Style::new().fg(browser::UNFOCUSED_BORDER)
|
||||
Style::new().fg(colors.browser.unfocused_border)
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
@@ -90,16 +91,16 @@ impl<'a> SampleBrowser<'a> {
|
||||
};
|
||||
|
||||
if let Some(sa) = search_area {
|
||||
self.render_search(frame, sa);
|
||||
self.render_search(frame, sa, &colors);
|
||||
}
|
||||
self.render_tree(frame, list_area);
|
||||
self.render_tree(frame, list_area, &colors);
|
||||
}
|
||||
|
||||
fn render_search(&self, frame: &mut Frame, area: Rect) {
|
||||
fn render_search(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) {
|
||||
let style = if self.search_active {
|
||||
Style::new().fg(search::ACTIVE)
|
||||
Style::new().fg(colors.search.active)
|
||||
} else {
|
||||
Style::new().fg(search::INACTIVE)
|
||||
Style::new().fg(colors.search.inactive)
|
||||
};
|
||||
let cursor = if self.search_active { "_" } else { "" };
|
||||
let text = format!("/{}{}", self.search_query, cursor);
|
||||
@@ -107,7 +108,7 @@ impl<'a> SampleBrowser<'a> {
|
||||
frame.render_widget(Paragraph::new(vec![line]), area);
|
||||
}
|
||||
|
||||
fn render_tree(&self, frame: &mut Frame, area: Rect) {
|
||||
fn render_tree(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) {
|
||||
let height = area.height as usize;
|
||||
if self.entries.is_empty() {
|
||||
let msg = if self.search_query.is_empty() {
|
||||
@@ -115,7 +116,7 @@ impl<'a> SampleBrowser<'a> {
|
||||
} else {
|
||||
"No matches"
|
||||
};
|
||||
let line = Line::from(Span::styled(msg, Style::new().fg(browser::EMPTY_TEXT)));
|
||||
let line = Line::from(Span::styled(msg, Style::new().fg(colors.browser.empty_text)));
|
||||
frame.render_widget(Paragraph::new(vec![line]), area);
|
||||
return;
|
||||
}
|
||||
@@ -130,23 +131,23 @@ impl<'a> SampleBrowser<'a> {
|
||||
|
||||
let (icon, icon_color) = match entry.kind {
|
||||
TreeLineKind::Root { expanded: true } | TreeLineKind::Folder { expanded: true } => {
|
||||
("\u{25BC} ", browser::FOLDER_ICON)
|
||||
("\u{25BC} ", colors.browser.folder_icon)
|
||||
}
|
||||
TreeLineKind::Root { expanded: false }
|
||||
| TreeLineKind::Folder { expanded: false } => ("\u{25B6} ", browser::FOLDER_ICON),
|
||||
TreeLineKind::File => ("\u{266A} ", browser::FILE_ICON),
|
||||
| TreeLineKind::Folder { expanded: false } => ("\u{25B6} ", colors.browser.folder_icon),
|
||||
TreeLineKind::File => ("\u{266A} ", colors.browser.file_icon),
|
||||
};
|
||||
|
||||
let label_style = if is_cursor && self.focused {
|
||||
Style::new().fg(browser::SELECTED).add_modifier(Modifier::BOLD)
|
||||
Style::new().fg(colors.browser.selected).add_modifier(Modifier::BOLD)
|
||||
} else if is_cursor {
|
||||
Style::new().fg(browser::FILE)
|
||||
Style::new().fg(colors.browser.file)
|
||||
} else {
|
||||
match entry.kind {
|
||||
TreeLineKind::Root { .. } => {
|
||||
Style::new().fg(browser::ROOT).add_modifier(Modifier::BOLD)
|
||||
Style::new().fg(colors.browser.root).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
TreeLineKind::Folder { .. } => Style::new().fg(browser::DIRECTORY),
|
||||
TreeLineKind::Folder { .. } => Style::new().fg(colors.browser.directory),
|
||||
TreeLineKind::File => Style::default(),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::theme::meter;
|
||||
use crate::theme;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
@@ -18,7 +18,7 @@ pub enum Orientation {
|
||||
pub struct Scope<'a> {
|
||||
data: &'a [f32],
|
||||
orientation: Orientation,
|
||||
color: Color,
|
||||
color: Option<Color>,
|
||||
gain: f32,
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ impl<'a> Scope<'a> {
|
||||
Self {
|
||||
data,
|
||||
orientation: Orientation::Horizontal,
|
||||
color: meter::LOW,
|
||||
color: None,
|
||||
gain: 1.0,
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ impl<'a> Scope<'a> {
|
||||
}
|
||||
|
||||
pub fn color(mut self, c: Color) -> Self {
|
||||
self.color = c;
|
||||
self.color = Some(c);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -49,11 +49,13 @@ impl Widget for Scope<'_> {
|
||||
return;
|
||||
}
|
||||
|
||||
let color = self.color.unwrap_or_else(|| theme::get().meter.low);
|
||||
|
||||
match self.orientation {
|
||||
Orientation::Horizontal => {
|
||||
render_horizontal(self.data, area, buf, self.color, self.gain)
|
||||
render_horizontal(self.data, area, buf, color, self.gain)
|
||||
}
|
||||
Orientation::Vertical => render_vertical(self.data, area, buf, self.color, self.gain),
|
||||
Orientation::Vertical => render_vertical(self.data, area, buf, color, self.gain),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,7 +66,6 @@ fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, g
|
||||
let fine_width = width * 2;
|
||||
let fine_height = height * 4;
|
||||
|
||||
// Auto-scale: find peak amplitude and normalize to fill height
|
||||
let peak = data.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
|
||||
let auto_gain = if peak > 0.001 { gain / peak } else { gain };
|
||||
|
||||
@@ -121,7 +122,6 @@ fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gai
|
||||
let fine_width = width * 2;
|
||||
let fine_height = height * 4;
|
||||
|
||||
// Auto-scale: find peak amplitude and normalize to fill width
|
||||
let peak = data.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
|
||||
let auto_gain = if peak > 0.001 { gain / peak } else { gain };
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::theme::sparkle;
|
||||
use crate::theme;
|
||||
use rand::Rng;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -41,8 +41,9 @@ impl Sparkles {
|
||||
|
||||
impl Widget for &Sparkles {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = theme::get().sparkle.colors;
|
||||
for sp in &self.sparkles {
|
||||
let color = sparkle::COLORS[sp.char_idx % sparkle::COLORS.len()];
|
||||
let color = colors[sp.char_idx % colors.len()];
|
||||
let intensity = (sp.life as f32 / 30.0).min(1.0);
|
||||
let r = (color.0 as f32 * intensity) as u8;
|
||||
let g = (color.1 as f32 * intensity) as u8;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::theme::meter;
|
||||
use crate::theme;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
@@ -22,6 +22,7 @@ impl Widget for Spectrum<'_> {
|
||||
return;
|
||||
}
|
||||
|
||||
let colors = theme::get();
|
||||
let height = area.height as f32;
|
||||
let band_width = area.width as usize / 32;
|
||||
if band_width == 0 {
|
||||
@@ -40,11 +41,11 @@ impl Widget for Spectrum<'_> {
|
||||
let y = area.y + area.height - 1 - row as u16;
|
||||
let ratio = row as f32 / area.height as f32;
|
||||
let color = if ratio < 0.33 {
|
||||
Color::Rgb(meter::LOW_RGB.0, meter::LOW_RGB.1, meter::LOW_RGB.2)
|
||||
Color::Rgb(colors.meter.low_rgb.0, colors.meter.low_rgb.1, colors.meter.low_rgb.2)
|
||||
} else if ratio < 0.66 {
|
||||
Color::Rgb(meter::MID_RGB.0, meter::MID_RGB.1, meter::MID_RGB.2)
|
||||
Color::Rgb(colors.meter.mid_rgb.0, colors.meter.mid_rgb.1, colors.meter.mid_rgb.2)
|
||||
} else {
|
||||
Color::Rgb(meter::HIGH_RGB.0, meter::HIGH_RGB.1, meter::HIGH_RGB.2)
|
||||
Color::Rgb(colors.meter.high_rgb.0, colors.meter.high_rgb.1, colors.meter.high_rgb.2)
|
||||
};
|
||||
for dx in 0..band_width as u16 {
|
||||
let x = x_start + dx;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::theme::{input, ui};
|
||||
use crate::theme;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
@@ -11,7 +11,7 @@ pub struct TextInputModal<'a> {
|
||||
title: &'a str,
|
||||
input: &'a str,
|
||||
hint: Option<&'a str>,
|
||||
border_color: Color,
|
||||
border_color: Option<Color>,
|
||||
width: u16,
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ impl<'a> TextInputModal<'a> {
|
||||
title,
|
||||
input,
|
||||
hint: None,
|
||||
border_color: ui::TEXT_PRIMARY,
|
||||
border_color: None,
|
||||
width: 50,
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ impl<'a> TextInputModal<'a> {
|
||||
}
|
||||
|
||||
pub fn border_color(mut self, c: Color) -> Self {
|
||||
self.border_color = c;
|
||||
self.border_color = Some(c);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -42,12 +42,14 @@ impl<'a> TextInputModal<'a> {
|
||||
}
|
||||
|
||||
pub fn render_centered(self, frame: &mut Frame, term: Rect) {
|
||||
let colors = theme::get();
|
||||
let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
|
||||
let height = if self.hint.is_some() { 6 } else { 5 };
|
||||
|
||||
let inner = ModalFrame::new(self.title)
|
||||
.width(self.width)
|
||||
.height(height)
|
||||
.border_color(self.border_color)
|
||||
.border_color(border_color)
|
||||
.render_centered(frame, term);
|
||||
|
||||
if self.hint.is_some() {
|
||||
@@ -57,15 +59,15 @@ impl<'a> TextInputModal<'a> {
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw("> "),
|
||||
Span::styled(self.input, Style::new().fg(input::TEXT)),
|
||||
Span::styled("█", Style::new().fg(input::CURSOR)),
|
||||
Span::styled(self.input, Style::new().fg(colors.input.text)),
|
||||
Span::styled("█", Style::new().fg(colors.input.cursor)),
|
||||
])),
|
||||
rows[0],
|
||||
);
|
||||
|
||||
if let Some(hint) = self.hint {
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(hint, Style::new().fg(input::HINT))),
|
||||
Paragraph::new(Span::styled(hint, Style::new().fg(colors.input.hint))),
|
||||
rows[1],
|
||||
);
|
||||
}
|
||||
@@ -73,8 +75,8 @@ impl<'a> TextInputModal<'a> {
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw("> "),
|
||||
Span::styled(self.input, Style::new().fg(input::TEXT)),
|
||||
Span::styled("█", Style::new().fg(input::CURSOR)),
|
||||
Span::styled(self.input, Style::new().fg(colors.input.text)),
|
||||
Span::styled("█", Style::new().fg(colors.input.cursor)),
|
||||
])),
|
||||
inner,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
282
crates/ratatui/src/theme/catppuccin_latte.rs
Normal file
282
crates/ratatui/src/theme/catppuccin_latte.rs
Normal file
@@ -0,0 +1,282 @@
|
||||
use super::*;
|
||||
use ratatui::style::Color;
|
||||
|
||||
pub fn theme() -> ThemeColors {
|
||||
let crust = Color::Rgb(220, 224, 232);
|
||||
let mantle = Color::Rgb(230, 233, 239);
|
||||
let base = Color::Rgb(239, 241, 245);
|
||||
let surface0 = Color::Rgb(204, 208, 218);
|
||||
let surface1 = Color::Rgb(188, 192, 204);
|
||||
let overlay0 = Color::Rgb(156, 160, 176);
|
||||
let overlay1 = Color::Rgb(140, 143, 161);
|
||||
let subtext0 = Color::Rgb(108, 111, 133);
|
||||
let subtext1 = Color::Rgb(92, 95, 119);
|
||||
let text = Color::Rgb(76, 79, 105);
|
||||
let pink = Color::Rgb(234, 118, 203);
|
||||
let mauve = Color::Rgb(136, 57, 239);
|
||||
let red = Color::Rgb(210, 15, 57);
|
||||
let maroon = Color::Rgb(230, 69, 83);
|
||||
let peach = Color::Rgb(254, 100, 11);
|
||||
let yellow = Color::Rgb(223, 142, 29);
|
||||
let green = Color::Rgb(64, 160, 43);
|
||||
let teal = Color::Rgb(23, 146, 153);
|
||||
let sapphire = Color::Rgb(32, 159, 181);
|
||||
let lavender = Color::Rgb(114, 135, 253);
|
||||
|
||||
ThemeColors {
|
||||
ui: UiColors {
|
||||
bg: base,
|
||||
bg_rgb: (239, 241, 245),
|
||||
text_primary: text,
|
||||
text_muted: subtext0,
|
||||
text_dim: overlay1,
|
||||
border: surface1,
|
||||
header: lavender,
|
||||
unfocused: overlay0,
|
||||
accent: mauve,
|
||||
surface: surface0,
|
||||
},
|
||||
status: StatusColors {
|
||||
playing_bg: Color::Rgb(220, 240, 225),
|
||||
playing_fg: green,
|
||||
stopped_bg: Color::Rgb(245, 220, 225),
|
||||
stopped_fg: red,
|
||||
fill_on: green,
|
||||
fill_off: overlay0,
|
||||
fill_bg: surface0,
|
||||
},
|
||||
selection: SelectionColors {
|
||||
cursor_bg: mauve,
|
||||
cursor_fg: base,
|
||||
selected_bg: Color::Rgb(200, 200, 230),
|
||||
selected_fg: lavender,
|
||||
in_range_bg: Color::Rgb(210, 210, 235),
|
||||
in_range_fg: subtext1,
|
||||
cursor: mauve,
|
||||
selected: Color::Rgb(200, 200, 230),
|
||||
in_range: Color::Rgb(210, 210, 235),
|
||||
},
|
||||
tile: TileColors {
|
||||
playing_active_bg: Color::Rgb(250, 220, 210),
|
||||
playing_active_fg: peach,
|
||||
playing_inactive_bg: Color::Rgb(250, 235, 200),
|
||||
playing_inactive_fg: yellow,
|
||||
active_bg: Color::Rgb(200, 235, 235),
|
||||
active_fg: teal,
|
||||
inactive_bg: surface0,
|
||||
inactive_fg: subtext0,
|
||||
active_selected_bg: Color::Rgb(215, 210, 240),
|
||||
active_in_range_bg: Color::Rgb(210, 215, 230),
|
||||
link_bright: [
|
||||
(136, 57, 239),
|
||||
(234, 118, 203),
|
||||
(254, 100, 11),
|
||||
(4, 165, 229),
|
||||
(64, 160, 43),
|
||||
],
|
||||
link_dim: [
|
||||
(210, 200, 240),
|
||||
(240, 210, 230),
|
||||
(250, 220, 200),
|
||||
(200, 230, 240),
|
||||
(210, 235, 210),
|
||||
],
|
||||
},
|
||||
header: HeaderColors {
|
||||
tempo_bg: Color::Rgb(220, 210, 240),
|
||||
tempo_fg: mauve,
|
||||
bank_bg: Color::Rgb(200, 230, 235),
|
||||
bank_fg: sapphire,
|
||||
pattern_bg: Color::Rgb(200, 230, 225),
|
||||
pattern_fg: teal,
|
||||
stats_bg: surface0,
|
||||
stats_fg: subtext0,
|
||||
},
|
||||
modal: ModalColors {
|
||||
border: lavender,
|
||||
border_accent: mauve,
|
||||
border_warn: peach,
|
||||
border_dim: overlay1,
|
||||
confirm: peach,
|
||||
rename: mauve,
|
||||
input: sapphire,
|
||||
editor: lavender,
|
||||
preview: overlay1,
|
||||
},
|
||||
flash: FlashColors {
|
||||
error_bg: Color::Rgb(250, 215, 220),
|
||||
error_fg: red,
|
||||
success_bg: Color::Rgb(210, 240, 215),
|
||||
success_fg: green,
|
||||
info_bg: surface0,
|
||||
info_fg: text,
|
||||
event_rgb: (225, 215, 240),
|
||||
},
|
||||
list: ListColors {
|
||||
playing_bg: Color::Rgb(210, 235, 220),
|
||||
playing_fg: green,
|
||||
staged_play_bg: Color::Rgb(225, 215, 245),
|
||||
staged_play_fg: mauve,
|
||||
staged_stop_bg: Color::Rgb(245, 215, 225),
|
||||
staged_stop_fg: maroon,
|
||||
edit_bg: Color::Rgb(210, 235, 235),
|
||||
edit_fg: teal,
|
||||
hover_bg: surface1,
|
||||
hover_fg: text,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: red,
|
||||
connected: green,
|
||||
listening: yellow,
|
||||
},
|
||||
syntax: SyntaxColors {
|
||||
gap_bg: mantle,
|
||||
executed_bg: Color::Rgb(225, 220, 240),
|
||||
selected_bg: Color::Rgb(250, 235, 210),
|
||||
emit: (text, Color::Rgb(250, 220, 215)),
|
||||
number: (peach, Color::Rgb(252, 235, 220)),
|
||||
string: (green, Color::Rgb(215, 240, 215)),
|
||||
comment: (overlay1, crust),
|
||||
keyword: (mauve, Color::Rgb(230, 220, 245)),
|
||||
stack_op: (sapphire, Color::Rgb(215, 230, 240)),
|
||||
operator: (yellow, Color::Rgb(245, 235, 210)),
|
||||
sound: (teal, Color::Rgb(210, 240, 240)),
|
||||
param: (lavender, Color::Rgb(220, 225, 245)),
|
||||
context: (peach, Color::Rgb(252, 235, 220)),
|
||||
note: (green, Color::Rgb(215, 240, 215)),
|
||||
interval: (Color::Rgb(50, 140, 30), Color::Rgb(215, 240, 210)),
|
||||
variable: (pink, Color::Rgb(245, 220, 240)),
|
||||
vary: (yellow, Color::Rgb(245, 235, 210)),
|
||||
generator: (teal, Color::Rgb(210, 240, 235)),
|
||||
default: (subtext0, mantle),
|
||||
},
|
||||
table: TableColors {
|
||||
row_even: mantle,
|
||||
row_odd: base,
|
||||
},
|
||||
values: ValuesColors {
|
||||
tempo: peach,
|
||||
value: subtext0,
|
||||
},
|
||||
hint: HintColors {
|
||||
key: peach,
|
||||
text: overlay1,
|
||||
},
|
||||
view_badge: ViewBadgeColors { bg: text, fg: base },
|
||||
nav: NavColors {
|
||||
selected_bg: Color::Rgb(215, 205, 245),
|
||||
selected_fg: text,
|
||||
unselected_bg: surface0,
|
||||
unselected_fg: overlay1,
|
||||
},
|
||||
editor_widget: EditorWidgetColors {
|
||||
cursor_bg: text,
|
||||
cursor_fg: base,
|
||||
selection_bg: Color::Rgb(200, 210, 240),
|
||||
completion_bg: surface0,
|
||||
completion_fg: text,
|
||||
completion_selected: peach,
|
||||
completion_example: teal,
|
||||
},
|
||||
browser: BrowserColors {
|
||||
directory: sapphire,
|
||||
project_file: mauve,
|
||||
selected: peach,
|
||||
file: text,
|
||||
focused_border: peach,
|
||||
unfocused_border: overlay0,
|
||||
root: text,
|
||||
file_icon: overlay1,
|
||||
folder_icon: sapphire,
|
||||
empty_text: overlay1,
|
||||
},
|
||||
input: InputColors {
|
||||
text: sapphire,
|
||||
cursor: text,
|
||||
hint: overlay1,
|
||||
},
|
||||
search: SearchColors {
|
||||
active: peach,
|
||||
inactive: overlay0,
|
||||
match_bg: yellow,
|
||||
match_fg: base,
|
||||
},
|
||||
markdown: MarkdownColors {
|
||||
h1: sapphire,
|
||||
h2: peach,
|
||||
h3: mauve,
|
||||
code: green,
|
||||
code_border: Color::Rgb(190, 195, 205),
|
||||
link: teal,
|
||||
link_url: Color::Rgb(150, 150, 150),
|
||||
quote: overlay1,
|
||||
text,
|
||||
list: text,
|
||||
},
|
||||
engine: EngineColors {
|
||||
header: Color::Rgb(30, 120, 150),
|
||||
header_focused: yellow,
|
||||
divider: Color::Rgb(180, 185, 195),
|
||||
scroll_indicator: Color::Rgb(160, 165, 175),
|
||||
label: Color::Rgb(100, 105, 120),
|
||||
label_focused: Color::Rgb(70, 75, 90),
|
||||
label_dim: Color::Rgb(120, 125, 140),
|
||||
value: Color::Rgb(60, 65, 80),
|
||||
focused: yellow,
|
||||
normal: text,
|
||||
dim: Color::Rgb(160, 165, 175),
|
||||
path: Color::Rgb(100, 105, 120),
|
||||
border_magenta: mauve,
|
||||
border_green: green,
|
||||
border_cyan: sapphire,
|
||||
separator: Color::Rgb(180, 185, 200),
|
||||
hint_active: Color::Rgb(180, 140, 40),
|
||||
hint_inactive: Color::Rgb(190, 195, 205),
|
||||
},
|
||||
dict: DictColors {
|
||||
word_name: green,
|
||||
word_bg: Color::Rgb(210, 225, 235),
|
||||
alias: overlay1,
|
||||
stack_sig: mauve,
|
||||
description: text,
|
||||
example: Color::Rgb(100, 105, 115),
|
||||
category_focused: yellow,
|
||||
category_selected: sapphire,
|
||||
category_normal: text,
|
||||
category_dimmed: Color::Rgb(160, 165, 175),
|
||||
border_focused: yellow,
|
||||
border_normal: Color::Rgb(180, 185, 195),
|
||||
header_desc: Color::Rgb(90, 95, 110),
|
||||
},
|
||||
title: TitleColors {
|
||||
big_title: mauve,
|
||||
author: lavender,
|
||||
link: teal,
|
||||
license: peach,
|
||||
prompt: Color::Rgb(90, 100, 115),
|
||||
subtitle: text,
|
||||
},
|
||||
meter: MeterColors {
|
||||
low: green,
|
||||
mid: yellow,
|
||||
high: red,
|
||||
low_rgb: (50, 150, 40),
|
||||
mid_rgb: (200, 140, 30),
|
||||
high_rgb: (200, 40, 50),
|
||||
},
|
||||
sparkle: SparkleColors {
|
||||
colors: [
|
||||
(114, 135, 253),
|
||||
(254, 100, 11),
|
||||
(64, 160, 43),
|
||||
(234, 118, 203),
|
||||
(136, 57, 239),
|
||||
],
|
||||
},
|
||||
confirm: ConfirmColors {
|
||||
border: peach,
|
||||
button_selected_bg: peach,
|
||||
button_selected_fg: base,
|
||||
},
|
||||
}
|
||||
}
|
||||
285
crates/ratatui/src/theme/catppuccin_mocha.rs
Normal file
285
crates/ratatui/src/theme/catppuccin_mocha.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
use super::*;
|
||||
use ratatui::style::Color;
|
||||
|
||||
pub fn theme() -> ThemeColors {
|
||||
let crust = Color::Rgb(17, 17, 27);
|
||||
let mantle = Color::Rgb(24, 24, 37);
|
||||
let base = Color::Rgb(30, 30, 46);
|
||||
let surface0 = Color::Rgb(49, 50, 68);
|
||||
let surface1 = Color::Rgb(69, 71, 90);
|
||||
let overlay0 = Color::Rgb(108, 112, 134);
|
||||
let overlay1 = Color::Rgb(127, 132, 156);
|
||||
let subtext0 = Color::Rgb(166, 173, 200);
|
||||
let subtext1 = Color::Rgb(186, 194, 222);
|
||||
let text = Color::Rgb(205, 214, 244);
|
||||
let pink = Color::Rgb(245, 194, 231);
|
||||
let mauve = Color::Rgb(203, 166, 247);
|
||||
let red = Color::Rgb(243, 139, 168);
|
||||
let maroon = Color::Rgb(235, 160, 172);
|
||||
let peach = Color::Rgb(250, 179, 135);
|
||||
let yellow = Color::Rgb(249, 226, 175);
|
||||
let green = Color::Rgb(166, 227, 161);
|
||||
let teal = Color::Rgb(148, 226, 213);
|
||||
let sapphire = Color::Rgb(116, 199, 236);
|
||||
let lavender = Color::Rgb(180, 190, 254);
|
||||
|
||||
ThemeColors {
|
||||
ui: UiColors {
|
||||
bg: base,
|
||||
bg_rgb: (30, 30, 46),
|
||||
text_primary: text,
|
||||
text_muted: subtext0,
|
||||
text_dim: overlay1,
|
||||
border: surface1,
|
||||
header: lavender,
|
||||
unfocused: overlay0,
|
||||
accent: mauve,
|
||||
surface: surface0,
|
||||
},
|
||||
status: StatusColors {
|
||||
playing_bg: Color::Rgb(30, 50, 40),
|
||||
playing_fg: green,
|
||||
stopped_bg: Color::Rgb(50, 30, 40),
|
||||
stopped_fg: red,
|
||||
fill_on: green,
|
||||
fill_off: overlay0,
|
||||
fill_bg: surface0,
|
||||
},
|
||||
selection: SelectionColors {
|
||||
cursor_bg: mauve,
|
||||
cursor_fg: crust,
|
||||
selected_bg: Color::Rgb(60, 60, 90),
|
||||
selected_fg: lavender,
|
||||
in_range_bg: Color::Rgb(50, 50, 75),
|
||||
in_range_fg: subtext1,
|
||||
cursor: mauve,
|
||||
selected: Color::Rgb(60, 60, 90),
|
||||
in_range: Color::Rgb(50, 50, 75),
|
||||
},
|
||||
tile: TileColors {
|
||||
playing_active_bg: Color::Rgb(80, 50, 60),
|
||||
playing_active_fg: peach,
|
||||
playing_inactive_bg: Color::Rgb(70, 55, 45),
|
||||
playing_inactive_fg: yellow,
|
||||
active_bg: Color::Rgb(40, 55, 55),
|
||||
active_fg: teal,
|
||||
inactive_bg: surface0,
|
||||
inactive_fg: subtext0,
|
||||
active_selected_bg: Color::Rgb(70, 60, 80),
|
||||
active_in_range_bg: Color::Rgb(55, 55, 70),
|
||||
link_bright: [
|
||||
(203, 166, 247),
|
||||
(245, 194, 231),
|
||||
(250, 179, 135),
|
||||
(137, 220, 235),
|
||||
(166, 227, 161),
|
||||
],
|
||||
link_dim: [
|
||||
(70, 55, 85),
|
||||
(85, 65, 80),
|
||||
(85, 60, 45),
|
||||
(45, 75, 80),
|
||||
(55, 80, 55),
|
||||
],
|
||||
},
|
||||
header: HeaderColors {
|
||||
tempo_bg: Color::Rgb(50, 40, 60),
|
||||
tempo_fg: mauve,
|
||||
bank_bg: Color::Rgb(35, 50, 55),
|
||||
bank_fg: sapphire,
|
||||
pattern_bg: Color::Rgb(40, 50, 50),
|
||||
pattern_fg: teal,
|
||||
stats_bg: surface0,
|
||||
stats_fg: subtext0,
|
||||
},
|
||||
modal: ModalColors {
|
||||
border: lavender,
|
||||
border_accent: mauve,
|
||||
border_warn: peach,
|
||||
border_dim: overlay1,
|
||||
confirm: peach,
|
||||
rename: mauve,
|
||||
input: sapphire,
|
||||
editor: lavender,
|
||||
preview: overlay1,
|
||||
},
|
||||
flash: FlashColors {
|
||||
error_bg: Color::Rgb(50, 30, 40),
|
||||
error_fg: red,
|
||||
success_bg: Color::Rgb(30, 50, 40),
|
||||
success_fg: green,
|
||||
info_bg: surface0,
|
||||
info_fg: text,
|
||||
event_rgb: (55, 45, 70),
|
||||
},
|
||||
list: ListColors {
|
||||
playing_bg: Color::Rgb(35, 55, 45),
|
||||
playing_fg: green,
|
||||
staged_play_bg: Color::Rgb(55, 45, 65),
|
||||
staged_play_fg: mauve,
|
||||
staged_stop_bg: Color::Rgb(60, 40, 50),
|
||||
staged_stop_fg: maroon,
|
||||
edit_bg: Color::Rgb(40, 55, 55),
|
||||
edit_fg: teal,
|
||||
hover_bg: surface1,
|
||||
hover_fg: text,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: red,
|
||||
connected: green,
|
||||
listening: yellow,
|
||||
},
|
||||
syntax: SyntaxColors {
|
||||
gap_bg: mantle,
|
||||
executed_bg: Color::Rgb(45, 40, 55),
|
||||
selected_bg: Color::Rgb(70, 55, 40),
|
||||
emit: (text, Color::Rgb(80, 50, 60)),
|
||||
number: (peach, Color::Rgb(55, 45, 35)),
|
||||
string: (green, Color::Rgb(35, 50, 40)),
|
||||
comment: (overlay1, crust),
|
||||
keyword: (mauve, Color::Rgb(50, 40, 60)),
|
||||
stack_op: (sapphire, Color::Rgb(35, 45, 55)),
|
||||
operator: (yellow, Color::Rgb(55, 50, 35)),
|
||||
sound: (teal, Color::Rgb(35, 55, 55)),
|
||||
param: (lavender, Color::Rgb(45, 45, 60)),
|
||||
context: (peach, Color::Rgb(55, 45, 35)),
|
||||
note: (green, Color::Rgb(35, 50, 40)),
|
||||
interval: (Color::Rgb(180, 230, 150), Color::Rgb(40, 55, 35)),
|
||||
variable: (pink, Color::Rgb(55, 40, 55)),
|
||||
vary: (yellow, Color::Rgb(55, 50, 35)),
|
||||
generator: (teal, Color::Rgb(35, 55, 50)),
|
||||
default: (subtext0, mantle),
|
||||
},
|
||||
table: TableColors {
|
||||
row_even: mantle,
|
||||
row_odd: base,
|
||||
},
|
||||
values: ValuesColors {
|
||||
tempo: peach,
|
||||
value: subtext0,
|
||||
},
|
||||
hint: HintColors {
|
||||
key: peach,
|
||||
text: overlay1,
|
||||
},
|
||||
view_badge: ViewBadgeColors {
|
||||
bg: text,
|
||||
fg: crust,
|
||||
},
|
||||
nav: NavColors {
|
||||
selected_bg: Color::Rgb(60, 50, 75),
|
||||
selected_fg: text,
|
||||
unselected_bg: surface0,
|
||||
unselected_fg: overlay1,
|
||||
},
|
||||
editor_widget: EditorWidgetColors {
|
||||
cursor_bg: text,
|
||||
cursor_fg: crust,
|
||||
selection_bg: Color::Rgb(50, 60, 90),
|
||||
completion_bg: surface0,
|
||||
completion_fg: text,
|
||||
completion_selected: peach,
|
||||
completion_example: teal,
|
||||
},
|
||||
browser: BrowserColors {
|
||||
directory: sapphire,
|
||||
project_file: mauve,
|
||||
selected: peach,
|
||||
file: text,
|
||||
focused_border: peach,
|
||||
unfocused_border: overlay0,
|
||||
root: text,
|
||||
file_icon: overlay1,
|
||||
folder_icon: sapphire,
|
||||
empty_text: overlay1,
|
||||
},
|
||||
input: InputColors {
|
||||
text: sapphire,
|
||||
cursor: text,
|
||||
hint: overlay1,
|
||||
},
|
||||
search: SearchColors {
|
||||
active: peach,
|
||||
inactive: overlay0,
|
||||
match_bg: yellow,
|
||||
match_fg: crust,
|
||||
},
|
||||
markdown: MarkdownColors {
|
||||
h1: sapphire,
|
||||
h2: peach,
|
||||
h3: mauve,
|
||||
code: green,
|
||||
code_border: Color::Rgb(60, 60, 70),
|
||||
link: teal,
|
||||
link_url: Color::Rgb(100, 100, 100),
|
||||
quote: overlay1,
|
||||
text,
|
||||
list: text,
|
||||
},
|
||||
engine: EngineColors {
|
||||
header: Color::Rgb(100, 160, 180),
|
||||
header_focused: yellow,
|
||||
divider: Color::Rgb(60, 65, 70),
|
||||
scroll_indicator: Color::Rgb(80, 85, 95),
|
||||
label: Color::Rgb(120, 125, 135),
|
||||
label_focused: Color::Rgb(150, 155, 165),
|
||||
label_dim: Color::Rgb(100, 105, 115),
|
||||
value: Color::Rgb(180, 180, 190),
|
||||
focused: yellow,
|
||||
normal: text,
|
||||
dim: Color::Rgb(80, 85, 95),
|
||||
path: Color::Rgb(120, 125, 135),
|
||||
border_magenta: mauve,
|
||||
border_green: green,
|
||||
border_cyan: sapphire,
|
||||
separator: Color::Rgb(60, 65, 75),
|
||||
hint_active: Color::Rgb(180, 180, 100),
|
||||
hint_inactive: Color::Rgb(60, 60, 70),
|
||||
},
|
||||
dict: DictColors {
|
||||
word_name: green,
|
||||
word_bg: Color::Rgb(40, 50, 60),
|
||||
alias: overlay1,
|
||||
stack_sig: mauve,
|
||||
description: text,
|
||||
example: Color::Rgb(120, 130, 140),
|
||||
category_focused: yellow,
|
||||
category_selected: sapphire,
|
||||
category_normal: text,
|
||||
category_dimmed: Color::Rgb(80, 80, 90),
|
||||
border_focused: yellow,
|
||||
border_normal: Color::Rgb(60, 60, 70),
|
||||
header_desc: Color::Rgb(140, 145, 155),
|
||||
},
|
||||
title: TitleColors {
|
||||
big_title: mauve,
|
||||
author: lavender,
|
||||
link: teal,
|
||||
license: peach,
|
||||
prompt: Color::Rgb(140, 160, 170),
|
||||
subtitle: text,
|
||||
},
|
||||
meter: MeterColors {
|
||||
low: green,
|
||||
mid: yellow,
|
||||
high: red,
|
||||
low_rgb: (40, 180, 80),
|
||||
mid_rgb: (220, 180, 40),
|
||||
high_rgb: (220, 60, 40),
|
||||
},
|
||||
sparkle: SparkleColors {
|
||||
colors: [
|
||||
(200, 220, 255),
|
||||
(250, 179, 135),
|
||||
(166, 227, 161),
|
||||
(245, 194, 231),
|
||||
(203, 166, 247),
|
||||
],
|
||||
},
|
||||
confirm: ConfirmColors {
|
||||
border: peach,
|
||||
button_selected_bg: peach,
|
||||
button_selected_fg: crust,
|
||||
},
|
||||
}
|
||||
}
|
||||
279
crates/ratatui/src/theme/dracula.rs
Normal file
279
crates/ratatui/src/theme/dracula.rs
Normal file
@@ -0,0 +1,279 @@
|
||||
use super::*;
|
||||
use ratatui::style::Color;
|
||||
|
||||
pub fn theme() -> ThemeColors {
|
||||
let background = Color::Rgb(40, 42, 54);
|
||||
let current_line = Color::Rgb(68, 71, 90);
|
||||
let foreground = Color::Rgb(248, 248, 242);
|
||||
let comment = Color::Rgb(98, 114, 164);
|
||||
let cyan = Color::Rgb(139, 233, 253);
|
||||
let green = Color::Rgb(80, 250, 123);
|
||||
let orange = Color::Rgb(255, 184, 108);
|
||||
let pink = Color::Rgb(255, 121, 198);
|
||||
let purple = Color::Rgb(189, 147, 249);
|
||||
let red = Color::Rgb(255, 85, 85);
|
||||
let yellow = Color::Rgb(241, 250, 140);
|
||||
|
||||
let darker_bg = Color::Rgb(33, 34, 44);
|
||||
let lighter_bg = Color::Rgb(55, 57, 70);
|
||||
|
||||
ThemeColors {
|
||||
ui: UiColors {
|
||||
bg: background,
|
||||
bg_rgb: (40, 42, 54),
|
||||
text_primary: foreground,
|
||||
text_muted: comment,
|
||||
text_dim: Color::Rgb(80, 85, 110),
|
||||
border: current_line,
|
||||
header: purple,
|
||||
unfocused: comment,
|
||||
accent: purple,
|
||||
surface: current_line,
|
||||
},
|
||||
status: StatusColors {
|
||||
playing_bg: Color::Rgb(40, 60, 50),
|
||||
playing_fg: green,
|
||||
stopped_bg: Color::Rgb(65, 45, 50),
|
||||
stopped_fg: red,
|
||||
fill_on: green,
|
||||
fill_off: comment,
|
||||
fill_bg: current_line,
|
||||
},
|
||||
selection: SelectionColors {
|
||||
cursor_bg: purple,
|
||||
cursor_fg: background,
|
||||
selected_bg: Color::Rgb(80, 75, 110),
|
||||
selected_fg: purple,
|
||||
in_range_bg: Color::Rgb(65, 65, 90),
|
||||
in_range_fg: foreground,
|
||||
cursor: purple,
|
||||
selected: Color::Rgb(80, 75, 110),
|
||||
in_range: Color::Rgb(65, 65, 90),
|
||||
},
|
||||
tile: TileColors {
|
||||
playing_active_bg: Color::Rgb(85, 60, 65),
|
||||
playing_active_fg: orange,
|
||||
playing_inactive_bg: Color::Rgb(80, 75, 55),
|
||||
playing_inactive_fg: yellow,
|
||||
active_bg: Color::Rgb(50, 70, 70),
|
||||
active_fg: cyan,
|
||||
inactive_bg: current_line,
|
||||
inactive_fg: comment,
|
||||
active_selected_bg: Color::Rgb(80, 70, 95),
|
||||
active_in_range_bg: Color::Rgb(65, 65, 85),
|
||||
link_bright: [
|
||||
(189, 147, 249),
|
||||
(255, 121, 198),
|
||||
(255, 184, 108),
|
||||
(139, 233, 253),
|
||||
(80, 250, 123),
|
||||
],
|
||||
link_dim: [
|
||||
(75, 60, 95),
|
||||
(95, 55, 80),
|
||||
(95, 70, 50),
|
||||
(55, 90, 95),
|
||||
(40, 95, 55),
|
||||
],
|
||||
},
|
||||
header: HeaderColors {
|
||||
tempo_bg: Color::Rgb(65, 50, 75),
|
||||
tempo_fg: purple,
|
||||
bank_bg: Color::Rgb(45, 65, 70),
|
||||
bank_fg: cyan,
|
||||
pattern_bg: Color::Rgb(40, 70, 60),
|
||||
pattern_fg: green,
|
||||
stats_bg: current_line,
|
||||
stats_fg: comment,
|
||||
},
|
||||
modal: ModalColors {
|
||||
border: purple,
|
||||
border_accent: pink,
|
||||
border_warn: orange,
|
||||
border_dim: comment,
|
||||
confirm: orange,
|
||||
rename: purple,
|
||||
input: cyan,
|
||||
editor: purple,
|
||||
preview: comment,
|
||||
},
|
||||
flash: FlashColors {
|
||||
error_bg: Color::Rgb(70, 45, 50),
|
||||
error_fg: red,
|
||||
success_bg: Color::Rgb(40, 65, 50),
|
||||
success_fg: green,
|
||||
info_bg: current_line,
|
||||
info_fg: foreground,
|
||||
event_rgb: (70, 55, 85),
|
||||
},
|
||||
list: ListColors {
|
||||
playing_bg: Color::Rgb(40, 65, 50),
|
||||
playing_fg: green,
|
||||
staged_play_bg: Color::Rgb(70, 55, 85),
|
||||
staged_play_fg: purple,
|
||||
staged_stop_bg: Color::Rgb(80, 50, 60),
|
||||
staged_stop_fg: red,
|
||||
edit_bg: Color::Rgb(45, 70, 70),
|
||||
edit_fg: cyan,
|
||||
hover_bg: lighter_bg,
|
||||
hover_fg: foreground,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: red,
|
||||
connected: green,
|
||||
listening: yellow,
|
||||
},
|
||||
syntax: SyntaxColors {
|
||||
gap_bg: darker_bg,
|
||||
executed_bg: Color::Rgb(55, 50, 70),
|
||||
selected_bg: Color::Rgb(85, 70, 50),
|
||||
emit: (foreground, Color::Rgb(85, 55, 65)),
|
||||
number: (orange, Color::Rgb(75, 55, 45)),
|
||||
string: (yellow, Color::Rgb(70, 70, 45)),
|
||||
comment: (comment, darker_bg),
|
||||
keyword: (pink, Color::Rgb(80, 50, 70)),
|
||||
stack_op: (cyan, Color::Rgb(45, 65, 75)),
|
||||
operator: (green, Color::Rgb(40, 70, 50)),
|
||||
sound: (cyan, Color::Rgb(45, 70, 70)),
|
||||
param: (purple, Color::Rgb(60, 50, 75)),
|
||||
context: (orange, Color::Rgb(75, 55, 45)),
|
||||
note: (green, Color::Rgb(40, 70, 50)),
|
||||
interval: (Color::Rgb(120, 255, 150), Color::Rgb(40, 75, 50)),
|
||||
variable: (pink, Color::Rgb(80, 50, 65)),
|
||||
vary: (yellow, Color::Rgb(70, 70, 45)),
|
||||
generator: (cyan, Color::Rgb(45, 70, 65)),
|
||||
default: (comment, darker_bg),
|
||||
},
|
||||
table: TableColors {
|
||||
row_even: darker_bg,
|
||||
row_odd: background,
|
||||
},
|
||||
values: ValuesColors {
|
||||
tempo: orange,
|
||||
value: comment,
|
||||
},
|
||||
hint: HintColors {
|
||||
key: orange,
|
||||
text: comment,
|
||||
},
|
||||
view_badge: ViewBadgeColors {
|
||||
bg: foreground,
|
||||
fg: background,
|
||||
},
|
||||
nav: NavColors {
|
||||
selected_bg: Color::Rgb(75, 65, 100),
|
||||
selected_fg: foreground,
|
||||
unselected_bg: current_line,
|
||||
unselected_fg: comment,
|
||||
},
|
||||
editor_widget: EditorWidgetColors {
|
||||
cursor_bg: foreground,
|
||||
cursor_fg: background,
|
||||
selection_bg: Color::Rgb(70, 75, 105),
|
||||
completion_bg: current_line,
|
||||
completion_fg: foreground,
|
||||
completion_selected: orange,
|
||||
completion_example: cyan,
|
||||
},
|
||||
browser: BrowserColors {
|
||||
directory: cyan,
|
||||
project_file: purple,
|
||||
selected: orange,
|
||||
file: foreground,
|
||||
focused_border: orange,
|
||||
unfocused_border: comment,
|
||||
root: foreground,
|
||||
file_icon: comment,
|
||||
folder_icon: cyan,
|
||||
empty_text: comment,
|
||||
},
|
||||
input: InputColors {
|
||||
text: cyan,
|
||||
cursor: foreground,
|
||||
hint: comment,
|
||||
},
|
||||
search: SearchColors {
|
||||
active: orange,
|
||||
inactive: comment,
|
||||
match_bg: yellow,
|
||||
match_fg: background,
|
||||
},
|
||||
markdown: MarkdownColors {
|
||||
h1: cyan,
|
||||
h2: orange,
|
||||
h3: purple,
|
||||
code: green,
|
||||
code_border: Color::Rgb(85, 90, 110),
|
||||
link: pink,
|
||||
link_url: Color::Rgb(120, 130, 150),
|
||||
quote: comment,
|
||||
text: foreground,
|
||||
list: foreground,
|
||||
},
|
||||
engine: EngineColors {
|
||||
header: cyan,
|
||||
header_focused: yellow,
|
||||
divider: Color::Rgb(80, 85, 105),
|
||||
scroll_indicator: Color::Rgb(95, 100, 120),
|
||||
label: Color::Rgb(140, 145, 165),
|
||||
label_focused: Color::Rgb(170, 175, 195),
|
||||
label_dim: Color::Rgb(110, 115, 135),
|
||||
value: Color::Rgb(200, 205, 220),
|
||||
focused: yellow,
|
||||
normal: foreground,
|
||||
dim: Color::Rgb(95, 100, 120),
|
||||
path: Color::Rgb(140, 145, 165),
|
||||
border_magenta: pink,
|
||||
border_green: green,
|
||||
border_cyan: cyan,
|
||||
separator: Color::Rgb(80, 85, 105),
|
||||
hint_active: Color::Rgb(220, 200, 100),
|
||||
hint_inactive: Color::Rgb(80, 85, 105),
|
||||
},
|
||||
dict: DictColors {
|
||||
word_name: green,
|
||||
word_bg: Color::Rgb(55, 65, 80),
|
||||
alias: comment,
|
||||
stack_sig: purple,
|
||||
description: foreground,
|
||||
example: Color::Rgb(140, 145, 165),
|
||||
category_focused: yellow,
|
||||
category_selected: cyan,
|
||||
category_normal: foreground,
|
||||
category_dimmed: Color::Rgb(95, 100, 120),
|
||||
border_focused: yellow,
|
||||
border_normal: Color::Rgb(80, 85, 105),
|
||||
header_desc: Color::Rgb(160, 165, 185),
|
||||
},
|
||||
title: TitleColors {
|
||||
big_title: purple,
|
||||
author: pink,
|
||||
link: cyan,
|
||||
license: orange,
|
||||
prompt: Color::Rgb(160, 165, 185),
|
||||
subtitle: foreground,
|
||||
},
|
||||
meter: MeterColors {
|
||||
low: green,
|
||||
mid: yellow,
|
||||
high: red,
|
||||
low_rgb: (70, 230, 110),
|
||||
mid_rgb: (230, 240, 130),
|
||||
high_rgb: (240, 80, 80),
|
||||
},
|
||||
sparkle: SparkleColors {
|
||||
colors: [
|
||||
(189, 147, 249),
|
||||
(255, 184, 108),
|
||||
(80, 250, 123),
|
||||
(255, 121, 198),
|
||||
(139, 233, 253),
|
||||
],
|
||||
},
|
||||
confirm: ConfirmColors {
|
||||
border: orange,
|
||||
button_selected_bg: orange,
|
||||
button_selected_fg: background,
|
||||
},
|
||||
}
|
||||
}
|
||||
278
crates/ratatui/src/theme/gruvbox_dark.rs
Normal file
278
crates/ratatui/src/theme/gruvbox_dark.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
use super::*;
|
||||
use ratatui::style::Color;
|
||||
|
||||
pub fn theme() -> ThemeColors {
|
||||
let bg0 = Color::Rgb(40, 40, 40);
|
||||
let bg1 = Color::Rgb(60, 56, 54);
|
||||
let bg2 = Color::Rgb(80, 73, 69);
|
||||
let fg = Color::Rgb(235, 219, 178);
|
||||
let fg2 = Color::Rgb(213, 196, 161);
|
||||
let fg3 = Color::Rgb(189, 174, 147);
|
||||
let fg4 = Color::Rgb(168, 153, 132);
|
||||
let red = Color::Rgb(251, 73, 52);
|
||||
let green = Color::Rgb(184, 187, 38);
|
||||
let yellow = Color::Rgb(250, 189, 47);
|
||||
let blue = Color::Rgb(131, 165, 152);
|
||||
let purple = Color::Rgb(211, 134, 155);
|
||||
let aqua = Color::Rgb(142, 192, 124);
|
||||
let orange = Color::Rgb(254, 128, 25);
|
||||
|
||||
let darker_bg = Color::Rgb(29, 32, 33);
|
||||
|
||||
ThemeColors {
|
||||
ui: UiColors {
|
||||
bg: bg0,
|
||||
bg_rgb: (40, 40, 40),
|
||||
text_primary: fg,
|
||||
text_muted: fg3,
|
||||
text_dim: fg4,
|
||||
border: bg2,
|
||||
header: yellow,
|
||||
unfocused: fg4,
|
||||
accent: orange,
|
||||
surface: bg1,
|
||||
},
|
||||
status: StatusColors {
|
||||
playing_bg: Color::Rgb(50, 60, 45),
|
||||
playing_fg: green,
|
||||
stopped_bg: Color::Rgb(65, 45, 45),
|
||||
stopped_fg: red,
|
||||
fill_on: green,
|
||||
fill_off: fg4,
|
||||
fill_bg: bg1,
|
||||
},
|
||||
selection: SelectionColors {
|
||||
cursor_bg: orange,
|
||||
cursor_fg: bg0,
|
||||
selected_bg: Color::Rgb(80, 70, 55),
|
||||
selected_fg: yellow,
|
||||
in_range_bg: Color::Rgb(65, 60, 50),
|
||||
in_range_fg: fg2,
|
||||
cursor: orange,
|
||||
selected: Color::Rgb(80, 70, 55),
|
||||
in_range: Color::Rgb(65, 60, 50),
|
||||
},
|
||||
tile: TileColors {
|
||||
playing_active_bg: Color::Rgb(90, 65, 50),
|
||||
playing_active_fg: orange,
|
||||
playing_inactive_bg: Color::Rgb(80, 75, 45),
|
||||
playing_inactive_fg: yellow,
|
||||
active_bg: Color::Rgb(50, 65, 55),
|
||||
active_fg: aqua,
|
||||
inactive_bg: bg1,
|
||||
inactive_fg: fg3,
|
||||
active_selected_bg: Color::Rgb(85, 70, 60),
|
||||
active_in_range_bg: Color::Rgb(70, 65, 55),
|
||||
link_bright: [
|
||||
(254, 128, 25),
|
||||
(211, 134, 155),
|
||||
(250, 189, 47),
|
||||
(131, 165, 152),
|
||||
(184, 187, 38),
|
||||
],
|
||||
link_dim: [
|
||||
(85, 55, 35),
|
||||
(75, 55, 65),
|
||||
(80, 70, 40),
|
||||
(50, 60, 60),
|
||||
(60, 65, 35),
|
||||
],
|
||||
},
|
||||
header: HeaderColors {
|
||||
tempo_bg: Color::Rgb(75, 55, 40),
|
||||
tempo_fg: orange,
|
||||
bank_bg: Color::Rgb(50, 60, 60),
|
||||
bank_fg: blue,
|
||||
pattern_bg: Color::Rgb(50, 65, 50),
|
||||
pattern_fg: aqua,
|
||||
stats_bg: bg1,
|
||||
stats_fg: fg3,
|
||||
},
|
||||
modal: ModalColors {
|
||||
border: yellow,
|
||||
border_accent: orange,
|
||||
border_warn: red,
|
||||
border_dim: fg4,
|
||||
confirm: orange,
|
||||
rename: purple,
|
||||
input: blue,
|
||||
editor: yellow,
|
||||
preview: fg4,
|
||||
},
|
||||
flash: FlashColors {
|
||||
error_bg: Color::Rgb(70, 45, 45),
|
||||
error_fg: red,
|
||||
success_bg: Color::Rgb(50, 65, 45),
|
||||
success_fg: green,
|
||||
info_bg: bg1,
|
||||
info_fg: fg,
|
||||
event_rgb: (70, 55, 45),
|
||||
},
|
||||
list: ListColors {
|
||||
playing_bg: Color::Rgb(50, 65, 45),
|
||||
playing_fg: green,
|
||||
staged_play_bg: Color::Rgb(70, 55, 60),
|
||||
staged_play_fg: purple,
|
||||
staged_stop_bg: Color::Rgb(75, 50, 50),
|
||||
staged_stop_fg: red,
|
||||
edit_bg: Color::Rgb(50, 65, 55),
|
||||
edit_fg: aqua,
|
||||
hover_bg: bg2,
|
||||
hover_fg: fg,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: red,
|
||||
connected: green,
|
||||
listening: yellow,
|
||||
},
|
||||
syntax: SyntaxColors {
|
||||
gap_bg: darker_bg,
|
||||
executed_bg: Color::Rgb(55, 50, 45),
|
||||
selected_bg: Color::Rgb(85, 70, 45),
|
||||
emit: (fg, Color::Rgb(80, 55, 50)),
|
||||
number: (orange, Color::Rgb(70, 50, 40)),
|
||||
string: (green, Color::Rgb(50, 60, 40)),
|
||||
comment: (fg4, darker_bg),
|
||||
keyword: (red, Color::Rgb(70, 45, 45)),
|
||||
stack_op: (blue, Color::Rgb(50, 55, 60)),
|
||||
operator: (yellow, Color::Rgb(70, 65, 40)),
|
||||
sound: (aqua, Color::Rgb(45, 60, 50)),
|
||||
param: (purple, Color::Rgb(65, 50, 55)),
|
||||
context: (orange, Color::Rgb(70, 50, 40)),
|
||||
note: (green, Color::Rgb(50, 60, 40)),
|
||||
interval: (Color::Rgb(170, 200, 100), Color::Rgb(55, 65, 40)),
|
||||
variable: (purple, Color::Rgb(65, 50, 55)),
|
||||
vary: (yellow, Color::Rgb(70, 65, 40)),
|
||||
generator: (aqua, Color::Rgb(45, 60, 50)),
|
||||
default: (fg3, darker_bg),
|
||||
},
|
||||
table: TableColors {
|
||||
row_even: darker_bg,
|
||||
row_odd: bg0,
|
||||
},
|
||||
values: ValuesColors {
|
||||
tempo: orange,
|
||||
value: fg3,
|
||||
},
|
||||
hint: HintColors {
|
||||
key: orange,
|
||||
text: fg4,
|
||||
},
|
||||
view_badge: ViewBadgeColors { bg: fg, fg: bg0 },
|
||||
nav: NavColors {
|
||||
selected_bg: Color::Rgb(80, 65, 50),
|
||||
selected_fg: fg,
|
||||
unselected_bg: bg1,
|
||||
unselected_fg: fg4,
|
||||
},
|
||||
editor_widget: EditorWidgetColors {
|
||||
cursor_bg: fg,
|
||||
cursor_fg: bg0,
|
||||
selection_bg: Color::Rgb(70, 65, 55),
|
||||
completion_bg: bg1,
|
||||
completion_fg: fg,
|
||||
completion_selected: orange,
|
||||
completion_example: aqua,
|
||||
},
|
||||
browser: BrowserColors {
|
||||
directory: blue,
|
||||
project_file: purple,
|
||||
selected: orange,
|
||||
file: fg,
|
||||
focused_border: orange,
|
||||
unfocused_border: fg4,
|
||||
root: fg,
|
||||
file_icon: fg4,
|
||||
folder_icon: blue,
|
||||
empty_text: fg4,
|
||||
},
|
||||
input: InputColors {
|
||||
text: blue,
|
||||
cursor: fg,
|
||||
hint: fg4,
|
||||
},
|
||||
search: SearchColors {
|
||||
active: orange,
|
||||
inactive: fg4,
|
||||
match_bg: yellow,
|
||||
match_fg: bg0,
|
||||
},
|
||||
markdown: MarkdownColors {
|
||||
h1: blue,
|
||||
h2: orange,
|
||||
h3: purple,
|
||||
code: green,
|
||||
code_border: Color::Rgb(80, 75, 70),
|
||||
link: aqua,
|
||||
link_url: Color::Rgb(120, 115, 105),
|
||||
quote: fg4,
|
||||
text: fg,
|
||||
list: fg,
|
||||
},
|
||||
engine: EngineColors {
|
||||
header: blue,
|
||||
header_focused: yellow,
|
||||
divider: Color::Rgb(75, 70, 65),
|
||||
scroll_indicator: Color::Rgb(90, 85, 80),
|
||||
label: Color::Rgb(145, 135, 125),
|
||||
label_focused: Color::Rgb(175, 165, 155),
|
||||
label_dim: Color::Rgb(115, 105, 95),
|
||||
value: Color::Rgb(200, 190, 175),
|
||||
focused: yellow,
|
||||
normal: fg,
|
||||
dim: Color::Rgb(90, 85, 80),
|
||||
path: Color::Rgb(145, 135, 125),
|
||||
border_magenta: purple,
|
||||
border_green: green,
|
||||
border_cyan: aqua,
|
||||
separator: Color::Rgb(75, 70, 65),
|
||||
hint_active: Color::Rgb(220, 180, 80),
|
||||
hint_inactive: Color::Rgb(75, 70, 65),
|
||||
},
|
||||
dict: DictColors {
|
||||
word_name: green,
|
||||
word_bg: Color::Rgb(55, 60, 55),
|
||||
alias: fg4,
|
||||
stack_sig: purple,
|
||||
description: fg,
|
||||
example: Color::Rgb(145, 135, 125),
|
||||
category_focused: yellow,
|
||||
category_selected: blue,
|
||||
category_normal: fg,
|
||||
category_dimmed: Color::Rgb(90, 85, 80),
|
||||
border_focused: yellow,
|
||||
border_normal: Color::Rgb(75, 70, 65),
|
||||
header_desc: Color::Rgb(165, 155, 145),
|
||||
},
|
||||
title: TitleColors {
|
||||
big_title: orange,
|
||||
author: yellow,
|
||||
link: aqua,
|
||||
license: purple,
|
||||
prompt: Color::Rgb(165, 155, 145),
|
||||
subtitle: fg,
|
||||
},
|
||||
meter: MeterColors {
|
||||
low: green,
|
||||
mid: yellow,
|
||||
high: red,
|
||||
low_rgb: (170, 175, 35),
|
||||
mid_rgb: (235, 180, 45),
|
||||
high_rgb: (240, 70, 50),
|
||||
},
|
||||
sparkle: SparkleColors {
|
||||
colors: [
|
||||
(250, 189, 47),
|
||||
(254, 128, 25),
|
||||
(184, 187, 38),
|
||||
(211, 134, 155),
|
||||
(131, 165, 152),
|
||||
],
|
||||
},
|
||||
confirm: ConfirmColors {
|
||||
border: orange,
|
||||
button_selected_bg: orange,
|
||||
button_selected_fg: bg0,
|
||||
},
|
||||
}
|
||||
}
|
||||
278
crates/ratatui/src/theme/kanagawa.rs
Normal file
278
crates/ratatui/src/theme/kanagawa.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
use super::*;
|
||||
use ratatui::style::Color;
|
||||
|
||||
pub fn theme() -> ThemeColors {
|
||||
let bg = Color::Rgb(31, 31, 40);
|
||||
let bg_light = Color::Rgb(43, 43, 54);
|
||||
let bg_lighter = Color::Rgb(54, 54, 70);
|
||||
let fg = Color::Rgb(220, 215, 186);
|
||||
let fg_dim = Color::Rgb(160, 158, 140);
|
||||
let comment = Color::Rgb(114, 113, 105);
|
||||
let crystal_blue = Color::Rgb(126, 156, 216);
|
||||
let oni_violet = Color::Rgb(149, 127, 184);
|
||||
let autumn_green = Color::Rgb(118, 148, 106);
|
||||
let autumn_red = Color::Rgb(195, 64, 67);
|
||||
let carp_yellow = Color::Rgb(230, 195, 132);
|
||||
let spring_blue = Color::Rgb(127, 180, 202);
|
||||
let wave_red = Color::Rgb(226, 109, 115);
|
||||
let sakura_pink = Color::Rgb(212, 140, 149);
|
||||
|
||||
let darker_bg = Color::Rgb(26, 26, 34);
|
||||
|
||||
ThemeColors {
|
||||
ui: UiColors {
|
||||
bg,
|
||||
bg_rgb: (31, 31, 40),
|
||||
text_primary: fg,
|
||||
text_muted: fg_dim,
|
||||
text_dim: comment,
|
||||
border: bg_lighter,
|
||||
header: crystal_blue,
|
||||
unfocused: comment,
|
||||
accent: sakura_pink,
|
||||
surface: bg_light,
|
||||
},
|
||||
status: StatusColors {
|
||||
playing_bg: Color::Rgb(40, 55, 45),
|
||||
playing_fg: autumn_green,
|
||||
stopped_bg: Color::Rgb(60, 40, 45),
|
||||
stopped_fg: autumn_red,
|
||||
fill_on: autumn_green,
|
||||
fill_off: comment,
|
||||
fill_bg: bg_light,
|
||||
},
|
||||
selection: SelectionColors {
|
||||
cursor_bg: sakura_pink,
|
||||
cursor_fg: bg,
|
||||
selected_bg: Color::Rgb(65, 55, 70),
|
||||
selected_fg: sakura_pink,
|
||||
in_range_bg: Color::Rgb(50, 50, 60),
|
||||
in_range_fg: fg,
|
||||
cursor: sakura_pink,
|
||||
selected: Color::Rgb(65, 55, 70),
|
||||
in_range: Color::Rgb(50, 50, 60),
|
||||
},
|
||||
tile: TileColors {
|
||||
playing_active_bg: Color::Rgb(65, 60, 50),
|
||||
playing_active_fg: carp_yellow,
|
||||
playing_inactive_bg: Color::Rgb(55, 55, 50),
|
||||
playing_inactive_fg: fg_dim,
|
||||
active_bg: Color::Rgb(45, 55, 70),
|
||||
active_fg: crystal_blue,
|
||||
inactive_bg: bg_light,
|
||||
inactive_fg: fg_dim,
|
||||
active_selected_bg: Color::Rgb(65, 55, 70),
|
||||
active_in_range_bg: Color::Rgb(50, 50, 60),
|
||||
link_bright: [
|
||||
(226, 109, 115),
|
||||
(149, 127, 184),
|
||||
(230, 195, 132),
|
||||
(127, 180, 202),
|
||||
(118, 148, 106),
|
||||
],
|
||||
link_dim: [
|
||||
(75, 45, 50),
|
||||
(55, 50, 70),
|
||||
(70, 60, 50),
|
||||
(45, 60, 70),
|
||||
(45, 55, 45),
|
||||
],
|
||||
},
|
||||
header: HeaderColors {
|
||||
tempo_bg: Color::Rgb(55, 50, 65),
|
||||
tempo_fg: oni_violet,
|
||||
bank_bg: Color::Rgb(45, 55, 70),
|
||||
bank_fg: crystal_blue,
|
||||
pattern_bg: Color::Rgb(45, 55, 45),
|
||||
pattern_fg: autumn_green,
|
||||
stats_bg: bg_light,
|
||||
stats_fg: fg_dim,
|
||||
},
|
||||
modal: ModalColors {
|
||||
border: crystal_blue,
|
||||
border_accent: sakura_pink,
|
||||
border_warn: carp_yellow,
|
||||
border_dim: comment,
|
||||
confirm: carp_yellow,
|
||||
rename: oni_violet,
|
||||
input: crystal_blue,
|
||||
editor: crystal_blue,
|
||||
preview: comment,
|
||||
},
|
||||
flash: FlashColors {
|
||||
error_bg: Color::Rgb(60, 40, 45),
|
||||
error_fg: wave_red,
|
||||
success_bg: Color::Rgb(40, 55, 45),
|
||||
success_fg: autumn_green,
|
||||
info_bg: bg_light,
|
||||
info_fg: fg,
|
||||
event_rgb: (50, 50, 60),
|
||||
},
|
||||
list: ListColors {
|
||||
playing_bg: Color::Rgb(40, 55, 45),
|
||||
playing_fg: autumn_green,
|
||||
staged_play_bg: Color::Rgb(55, 50, 70),
|
||||
staged_play_fg: oni_violet,
|
||||
staged_stop_bg: Color::Rgb(65, 45, 50),
|
||||
staged_stop_fg: wave_red,
|
||||
edit_bg: Color::Rgb(45, 55, 70),
|
||||
edit_fg: crystal_blue,
|
||||
hover_bg: bg_lighter,
|
||||
hover_fg: fg,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: autumn_red,
|
||||
connected: autumn_green,
|
||||
listening: carp_yellow,
|
||||
},
|
||||
syntax: SyntaxColors {
|
||||
gap_bg: darker_bg,
|
||||
executed_bg: Color::Rgb(45, 45, 55),
|
||||
selected_bg: Color::Rgb(65, 60, 50),
|
||||
emit: (fg, Color::Rgb(60, 50, 60)),
|
||||
number: (oni_violet, Color::Rgb(55, 50, 65)),
|
||||
string: (autumn_green, Color::Rgb(45, 55, 45)),
|
||||
comment: (comment, darker_bg),
|
||||
keyword: (sakura_pink, Color::Rgb(60, 50, 55)),
|
||||
stack_op: (spring_blue, Color::Rgb(45, 55, 65)),
|
||||
operator: (wave_red, Color::Rgb(60, 45, 50)),
|
||||
sound: (crystal_blue, Color::Rgb(45, 55, 70)),
|
||||
param: (carp_yellow, Color::Rgb(65, 60, 50)),
|
||||
context: (carp_yellow, Color::Rgb(65, 60, 50)),
|
||||
note: (autumn_green, Color::Rgb(45, 55, 45)),
|
||||
interval: (Color::Rgb(150, 180, 130), Color::Rgb(45, 55, 45)),
|
||||
variable: (autumn_green, Color::Rgb(45, 55, 45)),
|
||||
vary: (carp_yellow, Color::Rgb(65, 60, 50)),
|
||||
generator: (spring_blue, Color::Rgb(45, 55, 65)),
|
||||
default: (fg_dim, darker_bg),
|
||||
},
|
||||
table: TableColors {
|
||||
row_even: darker_bg,
|
||||
row_odd: bg,
|
||||
},
|
||||
values: ValuesColors {
|
||||
tempo: carp_yellow,
|
||||
value: fg_dim,
|
||||
},
|
||||
hint: HintColors {
|
||||
key: carp_yellow,
|
||||
text: comment,
|
||||
},
|
||||
view_badge: ViewBadgeColors { bg: fg, fg: bg },
|
||||
nav: NavColors {
|
||||
selected_bg: Color::Rgb(60, 50, 70),
|
||||
selected_fg: fg,
|
||||
unselected_bg: bg_light,
|
||||
unselected_fg: comment,
|
||||
},
|
||||
editor_widget: EditorWidgetColors {
|
||||
cursor_bg: fg,
|
||||
cursor_fg: bg,
|
||||
selection_bg: Color::Rgb(55, 55, 70),
|
||||
completion_bg: bg_light,
|
||||
completion_fg: fg,
|
||||
completion_selected: carp_yellow,
|
||||
completion_example: spring_blue,
|
||||
},
|
||||
browser: BrowserColors {
|
||||
directory: crystal_blue,
|
||||
project_file: oni_violet,
|
||||
selected: carp_yellow,
|
||||
file: fg,
|
||||
focused_border: carp_yellow,
|
||||
unfocused_border: comment,
|
||||
root: fg,
|
||||
file_icon: comment,
|
||||
folder_icon: crystal_blue,
|
||||
empty_text: comment,
|
||||
},
|
||||
input: InputColors {
|
||||
text: crystal_blue,
|
||||
cursor: fg,
|
||||
hint: comment,
|
||||
},
|
||||
search: SearchColors {
|
||||
active: carp_yellow,
|
||||
inactive: comment,
|
||||
match_bg: carp_yellow,
|
||||
match_fg: bg,
|
||||
},
|
||||
markdown: MarkdownColors {
|
||||
h1: crystal_blue,
|
||||
h2: carp_yellow,
|
||||
h3: oni_violet,
|
||||
code: autumn_green,
|
||||
code_border: Color::Rgb(65, 65, 80),
|
||||
link: sakura_pink,
|
||||
link_url: Color::Rgb(100, 100, 115),
|
||||
quote: comment,
|
||||
text: fg,
|
||||
list: fg,
|
||||
},
|
||||
engine: EngineColors {
|
||||
header: crystal_blue,
|
||||
header_focused: carp_yellow,
|
||||
divider: Color::Rgb(60, 60, 75),
|
||||
scroll_indicator: Color::Rgb(75, 75, 92),
|
||||
label: Color::Rgb(140, 138, 125),
|
||||
label_focused: Color::Rgb(170, 168, 155),
|
||||
label_dim: Color::Rgb(110, 108, 100),
|
||||
value: Color::Rgb(200, 195, 175),
|
||||
focused: carp_yellow,
|
||||
normal: fg,
|
||||
dim: Color::Rgb(75, 75, 92),
|
||||
path: Color::Rgb(140, 138, 125),
|
||||
border_magenta: oni_violet,
|
||||
border_green: autumn_green,
|
||||
border_cyan: spring_blue,
|
||||
separator: Color::Rgb(60, 60, 75),
|
||||
hint_active: Color::Rgb(220, 185, 120),
|
||||
hint_inactive: Color::Rgb(60, 60, 75),
|
||||
},
|
||||
dict: DictColors {
|
||||
word_name: autumn_green,
|
||||
word_bg: Color::Rgb(45, 50, 50),
|
||||
alias: comment,
|
||||
stack_sig: oni_violet,
|
||||
description: fg,
|
||||
example: Color::Rgb(140, 138, 125),
|
||||
category_focused: carp_yellow,
|
||||
category_selected: crystal_blue,
|
||||
category_normal: fg,
|
||||
category_dimmed: Color::Rgb(75, 75, 92),
|
||||
border_focused: carp_yellow,
|
||||
border_normal: Color::Rgb(60, 60, 75),
|
||||
header_desc: Color::Rgb(160, 158, 145),
|
||||
},
|
||||
title: TitleColors {
|
||||
big_title: sakura_pink,
|
||||
author: crystal_blue,
|
||||
link: autumn_green,
|
||||
license: carp_yellow,
|
||||
prompt: Color::Rgb(160, 158, 145),
|
||||
subtitle: fg,
|
||||
},
|
||||
meter: MeterColors {
|
||||
low: autumn_green,
|
||||
mid: carp_yellow,
|
||||
high: wave_red,
|
||||
low_rgb: (118, 148, 106),
|
||||
mid_rgb: (230, 195, 132),
|
||||
high_rgb: (226, 109, 115),
|
||||
},
|
||||
sparkle: SparkleColors {
|
||||
colors: [
|
||||
(127, 180, 202),
|
||||
(230, 195, 132),
|
||||
(118, 148, 106),
|
||||
(226, 109, 115),
|
||||
(149, 127, 184),
|
||||
],
|
||||
},
|
||||
confirm: ConfirmColors {
|
||||
border: carp_yellow,
|
||||
button_selected_bg: carp_yellow,
|
||||
button_selected_fg: bg,
|
||||
},
|
||||
}
|
||||
}
|
||||
373
crates/ratatui/src/theme/mod.rs
Normal file
373
crates/ratatui/src/theme/mod.rs
Normal file
@@ -0,0 +1,373 @@
|
||||
//! Centralized color definitions for Cagire TUI.
|
||||
//! Supports multiple color schemes with runtime switching.
|
||||
|
||||
mod catppuccin_latte;
|
||||
mod catppuccin_mocha;
|
||||
mod dracula;
|
||||
mod gruvbox_dark;
|
||||
mod kanagawa;
|
||||
mod monochrome_black;
|
||||
mod monochrome_white;
|
||||
mod monokai;
|
||||
mod nord;
|
||||
mod pitch_black;
|
||||
mod rose_pine;
|
||||
mod tokyo_night;
|
||||
|
||||
use ratatui::style::Color;
|
||||
use std::cell::RefCell;
|
||||
|
||||
pub struct ThemeEntry {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub colors: fn() -> ThemeColors,
|
||||
}
|
||||
|
||||
pub const THEMES: &[ThemeEntry] = &[
|
||||
ThemeEntry { id: "CatppuccinMocha", label: "Catppuccin Mocha", colors: catppuccin_mocha::theme },
|
||||
ThemeEntry { id: "CatppuccinLatte", label: "Catppuccin Latte", colors: catppuccin_latte::theme },
|
||||
ThemeEntry { id: "Nord", label: "Nord", colors: nord::theme },
|
||||
ThemeEntry { id: "Dracula", label: "Dracula", colors: dracula::theme },
|
||||
ThemeEntry { id: "GruvboxDark", label: "Gruvbox Dark", colors: gruvbox_dark::theme },
|
||||
ThemeEntry { id: "Monokai", label: "Monokai", colors: monokai::theme },
|
||||
ThemeEntry { id: "MonochromeBlack", label: "Monochrome (Black)", colors: monochrome_black::theme },
|
||||
ThemeEntry { id: "MonochromeWhite", label: "Monochrome (White)", colors: monochrome_white::theme },
|
||||
ThemeEntry { id: "PitchBlack", label: "Pitch Black", colors: pitch_black::theme },
|
||||
ThemeEntry { id: "TokyoNight", label: "Tokyo Night", colors: tokyo_night::theme },
|
||||
ThemeEntry { id: "RosePine", label: "Rosé Pine", colors: rose_pine::theme },
|
||||
ThemeEntry { id: "Kanagawa", label: "Kanagawa", colors: kanagawa::theme },
|
||||
];
|
||||
|
||||
thread_local! {
|
||||
static CURRENT_THEME: RefCell<ThemeColors> = RefCell::new((THEMES[0].colors)());
|
||||
}
|
||||
|
||||
pub fn get() -> ThemeColors {
|
||||
CURRENT_THEME.with(|t| t.borrow().clone())
|
||||
}
|
||||
|
||||
pub fn set(theme: ThemeColors) {
|
||||
CURRENT_THEME.with(|t| *t.borrow_mut() = theme);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ThemeColors {
|
||||
pub ui: UiColors,
|
||||
pub status: StatusColors,
|
||||
pub selection: SelectionColors,
|
||||
pub tile: TileColors,
|
||||
pub header: HeaderColors,
|
||||
pub modal: ModalColors,
|
||||
pub flash: FlashColors,
|
||||
pub list: ListColors,
|
||||
pub link_status: LinkStatusColors,
|
||||
pub syntax: SyntaxColors,
|
||||
pub table: TableColors,
|
||||
pub values: ValuesColors,
|
||||
pub hint: HintColors,
|
||||
pub view_badge: ViewBadgeColors,
|
||||
pub nav: NavColors,
|
||||
pub editor_widget: EditorWidgetColors,
|
||||
pub browser: BrowserColors,
|
||||
pub input: InputColors,
|
||||
pub search: SearchColors,
|
||||
pub markdown: MarkdownColors,
|
||||
pub engine: EngineColors,
|
||||
pub dict: DictColors,
|
||||
pub title: TitleColors,
|
||||
pub meter: MeterColors,
|
||||
pub sparkle: SparkleColors,
|
||||
pub confirm: ConfirmColors,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UiColors {
|
||||
pub bg: Color,
|
||||
pub bg_rgb: (u8, u8, u8),
|
||||
pub text_primary: Color,
|
||||
pub text_muted: Color,
|
||||
pub text_dim: Color,
|
||||
pub border: Color,
|
||||
pub header: Color,
|
||||
pub unfocused: Color,
|
||||
pub accent: Color,
|
||||
pub surface: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StatusColors {
|
||||
pub playing_bg: Color,
|
||||
pub playing_fg: Color,
|
||||
pub stopped_bg: Color,
|
||||
pub stopped_fg: Color,
|
||||
pub fill_on: Color,
|
||||
pub fill_off: Color,
|
||||
pub fill_bg: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SelectionColors {
|
||||
pub cursor_bg: Color,
|
||||
pub cursor_fg: Color,
|
||||
pub selected_bg: Color,
|
||||
pub selected_fg: Color,
|
||||
pub in_range_bg: Color,
|
||||
pub in_range_fg: Color,
|
||||
pub cursor: Color,
|
||||
pub selected: Color,
|
||||
pub in_range: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TileColors {
|
||||
pub playing_active_bg: Color,
|
||||
pub playing_active_fg: Color,
|
||||
pub playing_inactive_bg: Color,
|
||||
pub playing_inactive_fg: Color,
|
||||
pub active_bg: Color,
|
||||
pub active_fg: Color,
|
||||
pub inactive_bg: Color,
|
||||
pub inactive_fg: Color,
|
||||
pub active_selected_bg: Color,
|
||||
pub active_in_range_bg: Color,
|
||||
pub link_bright: [(u8, u8, u8); 5],
|
||||
pub link_dim: [(u8, u8, u8); 5],
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HeaderColors {
|
||||
pub tempo_bg: Color,
|
||||
pub tempo_fg: Color,
|
||||
pub bank_bg: Color,
|
||||
pub bank_fg: Color,
|
||||
pub pattern_bg: Color,
|
||||
pub pattern_fg: Color,
|
||||
pub stats_bg: Color,
|
||||
pub stats_fg: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ModalColors {
|
||||
pub border: Color,
|
||||
pub border_accent: Color,
|
||||
pub border_warn: Color,
|
||||
pub border_dim: Color,
|
||||
pub confirm: Color,
|
||||
pub rename: Color,
|
||||
pub input: Color,
|
||||
pub editor: Color,
|
||||
pub preview: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FlashColors {
|
||||
pub error_bg: Color,
|
||||
pub error_fg: Color,
|
||||
pub success_bg: Color,
|
||||
pub success_fg: Color,
|
||||
pub info_bg: Color,
|
||||
pub info_fg: Color,
|
||||
pub event_rgb: (u8, u8, u8),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ListColors {
|
||||
pub playing_bg: Color,
|
||||
pub playing_fg: Color,
|
||||
pub staged_play_bg: Color,
|
||||
pub staged_play_fg: Color,
|
||||
pub staged_stop_bg: Color,
|
||||
pub staged_stop_fg: Color,
|
||||
pub edit_bg: Color,
|
||||
pub edit_fg: Color,
|
||||
pub hover_bg: Color,
|
||||
pub hover_fg: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LinkStatusColors {
|
||||
pub disabled: Color,
|
||||
pub connected: Color,
|
||||
pub listening: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SyntaxColors {
|
||||
pub gap_bg: Color,
|
||||
pub executed_bg: Color,
|
||||
pub selected_bg: Color,
|
||||
pub emit: (Color, Color),
|
||||
pub number: (Color, Color),
|
||||
pub string: (Color, Color),
|
||||
pub comment: (Color, Color),
|
||||
pub keyword: (Color, Color),
|
||||
pub stack_op: (Color, Color),
|
||||
pub operator: (Color, Color),
|
||||
pub sound: (Color, Color),
|
||||
pub param: (Color, Color),
|
||||
pub context: (Color, Color),
|
||||
pub note: (Color, Color),
|
||||
pub interval: (Color, Color),
|
||||
pub variable: (Color, Color),
|
||||
pub vary: (Color, Color),
|
||||
pub generator: (Color, Color),
|
||||
pub default: (Color, Color),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TableColors {
|
||||
pub row_even: Color,
|
||||
pub row_odd: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ValuesColors {
|
||||
pub tempo: Color,
|
||||
pub value: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HintColors {
|
||||
pub key: Color,
|
||||
pub text: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ViewBadgeColors {
|
||||
pub bg: Color,
|
||||
pub fg: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NavColors {
|
||||
pub selected_bg: Color,
|
||||
pub selected_fg: Color,
|
||||
pub unselected_bg: Color,
|
||||
pub unselected_fg: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EditorWidgetColors {
|
||||
pub cursor_bg: Color,
|
||||
pub cursor_fg: Color,
|
||||
pub selection_bg: Color,
|
||||
pub completion_bg: Color,
|
||||
pub completion_fg: Color,
|
||||
pub completion_selected: Color,
|
||||
pub completion_example: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BrowserColors {
|
||||
pub directory: Color,
|
||||
pub project_file: Color,
|
||||
pub selected: Color,
|
||||
pub file: Color,
|
||||
pub focused_border: Color,
|
||||
pub unfocused_border: Color,
|
||||
pub root: Color,
|
||||
pub file_icon: Color,
|
||||
pub folder_icon: Color,
|
||||
pub empty_text: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct InputColors {
|
||||
pub text: Color,
|
||||
pub cursor: Color,
|
||||
pub hint: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SearchColors {
|
||||
pub active: Color,
|
||||
pub inactive: Color,
|
||||
pub match_bg: Color,
|
||||
pub match_fg: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MarkdownColors {
|
||||
pub h1: Color,
|
||||
pub h2: Color,
|
||||
pub h3: Color,
|
||||
pub code: Color,
|
||||
pub code_border: Color,
|
||||
pub link: Color,
|
||||
pub link_url: Color,
|
||||
pub quote: Color,
|
||||
pub text: Color,
|
||||
pub list: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EngineColors {
|
||||
pub header: Color,
|
||||
pub header_focused: Color,
|
||||
pub divider: Color,
|
||||
pub scroll_indicator: Color,
|
||||
pub label: Color,
|
||||
pub label_focused: Color,
|
||||
pub label_dim: Color,
|
||||
pub value: Color,
|
||||
pub focused: Color,
|
||||
pub normal: Color,
|
||||
pub dim: Color,
|
||||
pub path: Color,
|
||||
pub border_magenta: Color,
|
||||
pub border_green: Color,
|
||||
pub border_cyan: Color,
|
||||
pub separator: Color,
|
||||
pub hint_active: Color,
|
||||
pub hint_inactive: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DictColors {
|
||||
pub word_name: Color,
|
||||
pub word_bg: Color,
|
||||
pub alias: Color,
|
||||
pub stack_sig: Color,
|
||||
pub description: Color,
|
||||
pub example: Color,
|
||||
pub category_focused: Color,
|
||||
pub category_selected: Color,
|
||||
pub category_normal: Color,
|
||||
pub category_dimmed: Color,
|
||||
pub border_focused: Color,
|
||||
pub border_normal: Color,
|
||||
pub header_desc: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TitleColors {
|
||||
pub big_title: Color,
|
||||
pub author: Color,
|
||||
pub link: Color,
|
||||
pub license: Color,
|
||||
pub prompt: Color,
|
||||
pub subtitle: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MeterColors {
|
||||
pub low: Color,
|
||||
pub mid: Color,
|
||||
pub high: Color,
|
||||
pub low_rgb: (u8, u8, u8),
|
||||
pub mid_rgb: (u8, u8, u8),
|
||||
pub high_rgb: (u8, u8, u8),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SparkleColors {
|
||||
pub colors: [(u8, u8, u8); 5],
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConfirmColors {
|
||||
pub border: Color,
|
||||
pub button_selected_bg: Color,
|
||||
pub button_selected_fg: Color,
|
||||
}
|
||||
|
||||
275
crates/ratatui/src/theme/monochrome_black.rs
Normal file
275
crates/ratatui/src/theme/monochrome_black.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
use super::*;
|
||||
use ratatui::style::Color;
|
||||
|
||||
pub fn theme() -> ThemeColors {
|
||||
let bg = Color::Rgb(0, 0, 0);
|
||||
let surface = Color::Rgb(18, 18, 18);
|
||||
let surface2 = Color::Rgb(30, 30, 30);
|
||||
let border = Color::Rgb(60, 60, 60);
|
||||
let fg = Color::Rgb(255, 255, 255);
|
||||
let fg_dim = Color::Rgb(180, 180, 180);
|
||||
let fg_muted = Color::Rgb(120, 120, 120);
|
||||
|
||||
let bright = Color::Rgb(255, 255, 255);
|
||||
let medium = Color::Rgb(180, 180, 180);
|
||||
let dim = Color::Rgb(120, 120, 120);
|
||||
let dark = Color::Rgb(80, 80, 80);
|
||||
let darker = Color::Rgb(50, 50, 50);
|
||||
|
||||
ThemeColors {
|
||||
ui: UiColors {
|
||||
bg,
|
||||
bg_rgb: (0, 0, 0),
|
||||
text_primary: fg,
|
||||
text_muted: fg_dim,
|
||||
text_dim: fg_muted,
|
||||
border,
|
||||
header: bright,
|
||||
unfocused: fg_muted,
|
||||
accent: bright,
|
||||
surface,
|
||||
},
|
||||
status: StatusColors {
|
||||
playing_bg: Color::Rgb(40, 40, 40),
|
||||
playing_fg: bright,
|
||||
stopped_bg: Color::Rgb(25, 25, 25),
|
||||
stopped_fg: medium,
|
||||
fill_on: bright,
|
||||
fill_off: dark,
|
||||
fill_bg: surface,
|
||||
},
|
||||
selection: SelectionColors {
|
||||
cursor_bg: bright,
|
||||
cursor_fg: bg,
|
||||
selected_bg: Color::Rgb(60, 60, 60),
|
||||
selected_fg: bright,
|
||||
in_range_bg: Color::Rgb(40, 40, 40),
|
||||
in_range_fg: fg,
|
||||
cursor: bright,
|
||||
selected: Color::Rgb(60, 60, 60),
|
||||
in_range: Color::Rgb(40, 40, 40),
|
||||
},
|
||||
tile: TileColors {
|
||||
playing_active_bg: Color::Rgb(70, 70, 70),
|
||||
playing_active_fg: bright,
|
||||
playing_inactive_bg: Color::Rgb(50, 50, 50),
|
||||
playing_inactive_fg: medium,
|
||||
active_bg: Color::Rgb(45, 45, 45),
|
||||
active_fg: bright,
|
||||
inactive_bg: surface,
|
||||
inactive_fg: fg_dim,
|
||||
active_selected_bg: Color::Rgb(80, 80, 80),
|
||||
active_in_range_bg: Color::Rgb(55, 55, 55),
|
||||
link_bright: [
|
||||
(255, 255, 255),
|
||||
(200, 200, 200),
|
||||
(160, 160, 160),
|
||||
(220, 220, 220),
|
||||
(180, 180, 180),
|
||||
],
|
||||
link_dim: [
|
||||
(60, 60, 60),
|
||||
(50, 50, 50),
|
||||
(45, 45, 45),
|
||||
(55, 55, 55),
|
||||
(48, 48, 48),
|
||||
],
|
||||
},
|
||||
header: HeaderColors {
|
||||
tempo_bg: Color::Rgb(50, 50, 50),
|
||||
tempo_fg: bright,
|
||||
bank_bg: Color::Rgb(40, 40, 40),
|
||||
bank_fg: medium,
|
||||
pattern_bg: Color::Rgb(35, 35, 35),
|
||||
pattern_fg: medium,
|
||||
stats_bg: surface,
|
||||
stats_fg: fg_dim,
|
||||
},
|
||||
modal: ModalColors {
|
||||
border: bright,
|
||||
border_accent: medium,
|
||||
border_warn: fg_dim,
|
||||
border_dim: fg_muted,
|
||||
confirm: medium,
|
||||
rename: medium,
|
||||
input: bright,
|
||||
editor: bright,
|
||||
preview: fg_muted,
|
||||
},
|
||||
flash: FlashColors {
|
||||
error_bg: Color::Rgb(60, 60, 60),
|
||||
error_fg: bright,
|
||||
success_bg: Color::Rgb(50, 50, 50),
|
||||
success_fg: bright,
|
||||
info_bg: surface,
|
||||
info_fg: fg,
|
||||
event_rgb: (40, 40, 40),
|
||||
},
|
||||
list: ListColors {
|
||||
playing_bg: Color::Rgb(50, 50, 50),
|
||||
playing_fg: bright,
|
||||
staged_play_bg: Color::Rgb(45, 45, 45),
|
||||
staged_play_fg: medium,
|
||||
staged_stop_bg: Color::Rgb(35, 35, 35),
|
||||
staged_stop_fg: dim,
|
||||
edit_bg: Color::Rgb(40, 40, 40),
|
||||
edit_fg: bright,
|
||||
hover_bg: surface2,
|
||||
hover_fg: fg,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: dim,
|
||||
connected: bright,
|
||||
listening: medium,
|
||||
},
|
||||
syntax: SyntaxColors {
|
||||
gap_bg: bg,
|
||||
executed_bg: Color::Rgb(35, 35, 35),
|
||||
selected_bg: Color::Rgb(55, 55, 55),
|
||||
emit: (bright, Color::Rgb(45, 45, 45)),
|
||||
number: (medium, Color::Rgb(35, 35, 35)),
|
||||
string: (bright, Color::Rgb(40, 40, 40)),
|
||||
comment: (dark, bg),
|
||||
keyword: (bright, Color::Rgb(50, 50, 50)),
|
||||
stack_op: (medium, Color::Rgb(30, 30, 30)),
|
||||
operator: (medium, Color::Rgb(35, 35, 35)),
|
||||
sound: (bright, Color::Rgb(45, 45, 45)),
|
||||
param: (medium, Color::Rgb(35, 35, 35)),
|
||||
context: (medium, Color::Rgb(30, 30, 30)),
|
||||
note: (bright, Color::Rgb(40, 40, 40)),
|
||||
interval: (medium, Color::Rgb(35, 35, 35)),
|
||||
variable: (medium, Color::Rgb(30, 30, 30)),
|
||||
vary: (dim, Color::Rgb(25, 25, 25)),
|
||||
generator: (bright, Color::Rgb(45, 45, 45)),
|
||||
default: (fg_dim, bg),
|
||||
},
|
||||
table: TableColors {
|
||||
row_even: bg,
|
||||
row_odd: surface,
|
||||
},
|
||||
values: ValuesColors {
|
||||
tempo: bright,
|
||||
value: fg_dim,
|
||||
},
|
||||
hint: HintColors {
|
||||
key: bright,
|
||||
text: fg_muted,
|
||||
},
|
||||
view_badge: ViewBadgeColors { bg: fg, fg: bg },
|
||||
nav: NavColors {
|
||||
selected_bg: Color::Rgb(60, 60, 60),
|
||||
selected_fg: fg,
|
||||
unselected_bg: surface,
|
||||
unselected_fg: fg_muted,
|
||||
},
|
||||
editor_widget: EditorWidgetColors {
|
||||
cursor_bg: fg,
|
||||
cursor_fg: bg,
|
||||
selection_bg: Color::Rgb(60, 60, 60),
|
||||
completion_bg: surface,
|
||||
completion_fg: fg,
|
||||
completion_selected: bright,
|
||||
completion_example: medium,
|
||||
},
|
||||
browser: BrowserColors {
|
||||
directory: medium,
|
||||
project_file: bright,
|
||||
selected: bright,
|
||||
file: fg,
|
||||
focused_border: bright,
|
||||
unfocused_border: fg_muted,
|
||||
root: fg,
|
||||
file_icon: fg_muted,
|
||||
folder_icon: medium,
|
||||
empty_text: fg_muted,
|
||||
},
|
||||
input: InputColors {
|
||||
text: bright,
|
||||
cursor: fg,
|
||||
hint: fg_muted,
|
||||
},
|
||||
search: SearchColors {
|
||||
active: bright,
|
||||
inactive: fg_muted,
|
||||
match_bg: bright,
|
||||
match_fg: bg,
|
||||
},
|
||||
markdown: MarkdownColors {
|
||||
h1: bright,
|
||||
h2: medium,
|
||||
h3: dim,
|
||||
code: medium,
|
||||
code_border: Color::Rgb(60, 60, 60),
|
||||
link: bright,
|
||||
link_url: dim,
|
||||
quote: fg_muted,
|
||||
text: fg,
|
||||
list: fg,
|
||||
},
|
||||
engine: EngineColors {
|
||||
header: bright,
|
||||
header_focused: bright,
|
||||
divider: Color::Rgb(50, 50, 50),
|
||||
scroll_indicator: Color::Rgb(70, 70, 70),
|
||||
label: dim,
|
||||
label_focused: medium,
|
||||
label_dim: dark,
|
||||
value: fg,
|
||||
focused: bright,
|
||||
normal: fg,
|
||||
dim: dark,
|
||||
path: dim,
|
||||
border_magenta: medium,
|
||||
border_green: medium,
|
||||
border_cyan: medium,
|
||||
separator: Color::Rgb(50, 50, 50),
|
||||
hint_active: bright,
|
||||
hint_inactive: darker,
|
||||
},
|
||||
dict: DictColors {
|
||||
word_name: bright,
|
||||
word_bg: Color::Rgb(30, 30, 30),
|
||||
alias: fg_muted,
|
||||
stack_sig: medium,
|
||||
description: fg,
|
||||
example: dim,
|
||||
category_focused: bright,
|
||||
category_selected: medium,
|
||||
category_normal: fg,
|
||||
category_dimmed: dark,
|
||||
border_focused: bright,
|
||||
border_normal: darker,
|
||||
header_desc: dim,
|
||||
},
|
||||
title: TitleColors {
|
||||
big_title: bright,
|
||||
author: medium,
|
||||
link: medium,
|
||||
license: dim,
|
||||
prompt: dim,
|
||||
subtitle: fg,
|
||||
},
|
||||
meter: MeterColors {
|
||||
low: dim,
|
||||
mid: medium,
|
||||
high: bright,
|
||||
low_rgb: (120, 120, 120),
|
||||
mid_rgb: (180, 180, 180),
|
||||
high_rgb: (255, 255, 255),
|
||||
},
|
||||
sparkle: SparkleColors {
|
||||
colors: [
|
||||
(255, 255, 255),
|
||||
(200, 200, 200),
|
||||
(160, 160, 160),
|
||||
(220, 220, 220),
|
||||
(180, 180, 180),
|
||||
],
|
||||
},
|
||||
confirm: ConfirmColors {
|
||||
border: bright,
|
||||
button_selected_bg: bright,
|
||||
button_selected_fg: bg,
|
||||
},
|
||||
}
|
||||
}
|
||||
275
crates/ratatui/src/theme/monochrome_white.rs
Normal file
275
crates/ratatui/src/theme/monochrome_white.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
use super::*;
|
||||
use ratatui::style::Color;
|
||||
|
||||
pub fn theme() -> ThemeColors {
|
||||
let bg = Color::Rgb(255, 255, 255);
|
||||
let surface = Color::Rgb(240, 240, 240);
|
||||
let surface2 = Color::Rgb(225, 225, 225);
|
||||
let border = Color::Rgb(180, 180, 180);
|
||||
let fg = Color::Rgb(0, 0, 0);
|
||||
let fg_dim = Color::Rgb(80, 80, 80);
|
||||
let fg_muted = Color::Rgb(140, 140, 140);
|
||||
|
||||
let dark = Color::Rgb(0, 0, 0);
|
||||
let medium = Color::Rgb(80, 80, 80);
|
||||
let dim = Color::Rgb(140, 140, 140);
|
||||
let light = Color::Rgb(180, 180, 180);
|
||||
let lighter = Color::Rgb(210, 210, 210);
|
||||
|
||||
ThemeColors {
|
||||
ui: UiColors {
|
||||
bg,
|
||||
bg_rgb: (255, 255, 255),
|
||||
text_primary: fg,
|
||||
text_muted: fg_dim,
|
||||
text_dim: fg_muted,
|
||||
border,
|
||||
header: dark,
|
||||
unfocused: fg_muted,
|
||||
accent: dark,
|
||||
surface,
|
||||
},
|
||||
status: StatusColors {
|
||||
playing_bg: Color::Rgb(210, 210, 210),
|
||||
playing_fg: dark,
|
||||
stopped_bg: Color::Rgb(230, 230, 230),
|
||||
stopped_fg: medium,
|
||||
fill_on: dark,
|
||||
fill_off: light,
|
||||
fill_bg: surface,
|
||||
},
|
||||
selection: SelectionColors {
|
||||
cursor_bg: dark,
|
||||
cursor_fg: bg,
|
||||
selected_bg: Color::Rgb(200, 200, 200),
|
||||
selected_fg: dark,
|
||||
in_range_bg: Color::Rgb(220, 220, 220),
|
||||
in_range_fg: fg,
|
||||
cursor: dark,
|
||||
selected: Color::Rgb(200, 200, 200),
|
||||
in_range: Color::Rgb(220, 220, 220),
|
||||
},
|
||||
tile: TileColors {
|
||||
playing_active_bg: Color::Rgb(180, 180, 180),
|
||||
playing_active_fg: dark,
|
||||
playing_inactive_bg: Color::Rgb(200, 200, 200),
|
||||
playing_inactive_fg: medium,
|
||||
active_bg: Color::Rgb(210, 210, 210),
|
||||
active_fg: dark,
|
||||
inactive_bg: surface,
|
||||
inactive_fg: fg_dim,
|
||||
active_selected_bg: Color::Rgb(170, 170, 170),
|
||||
active_in_range_bg: Color::Rgb(195, 195, 195),
|
||||
link_bright: [
|
||||
(0, 0, 0),
|
||||
(60, 60, 60),
|
||||
(100, 100, 100),
|
||||
(40, 40, 40),
|
||||
(80, 80, 80),
|
||||
],
|
||||
link_dim: [
|
||||
(200, 200, 200),
|
||||
(210, 210, 210),
|
||||
(215, 215, 215),
|
||||
(205, 205, 205),
|
||||
(212, 212, 212),
|
||||
],
|
||||
},
|
||||
header: HeaderColors {
|
||||
tempo_bg: Color::Rgb(200, 200, 200),
|
||||
tempo_fg: dark,
|
||||
bank_bg: Color::Rgb(215, 215, 215),
|
||||
bank_fg: medium,
|
||||
pattern_bg: Color::Rgb(220, 220, 220),
|
||||
pattern_fg: medium,
|
||||
stats_bg: surface,
|
||||
stats_fg: fg_dim,
|
||||
},
|
||||
modal: ModalColors {
|
||||
border: dark,
|
||||
border_accent: medium,
|
||||
border_warn: fg_dim,
|
||||
border_dim: fg_muted,
|
||||
confirm: medium,
|
||||
rename: medium,
|
||||
input: dark,
|
||||
editor: dark,
|
||||
preview: fg_muted,
|
||||
},
|
||||
flash: FlashColors {
|
||||
error_bg: Color::Rgb(200, 200, 200),
|
||||
error_fg: dark,
|
||||
success_bg: Color::Rgb(210, 210, 210),
|
||||
success_fg: dark,
|
||||
info_bg: surface,
|
||||
info_fg: fg,
|
||||
event_rgb: (220, 220, 220),
|
||||
},
|
||||
list: ListColors {
|
||||
playing_bg: Color::Rgb(200, 200, 200),
|
||||
playing_fg: dark,
|
||||
staged_play_bg: Color::Rgb(210, 210, 210),
|
||||
staged_play_fg: medium,
|
||||
staged_stop_bg: Color::Rgb(220, 220, 220),
|
||||
staged_stop_fg: dim,
|
||||
edit_bg: Color::Rgb(215, 215, 215),
|
||||
edit_fg: dark,
|
||||
hover_bg: surface2,
|
||||
hover_fg: fg,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: dim,
|
||||
connected: dark,
|
||||
listening: medium,
|
||||
},
|
||||
syntax: SyntaxColors {
|
||||
gap_bg: bg,
|
||||
executed_bg: Color::Rgb(220, 220, 220),
|
||||
selected_bg: Color::Rgb(200, 200, 200),
|
||||
emit: (dark, Color::Rgb(215, 215, 215)),
|
||||
number: (medium, Color::Rgb(225, 225, 225)),
|
||||
string: (dark, Color::Rgb(220, 220, 220)),
|
||||
comment: (light, bg),
|
||||
keyword: (dark, Color::Rgb(205, 205, 205)),
|
||||
stack_op: (medium, Color::Rgb(230, 230, 230)),
|
||||
operator: (medium, Color::Rgb(225, 225, 225)),
|
||||
sound: (dark, Color::Rgb(215, 215, 215)),
|
||||
param: (medium, Color::Rgb(225, 225, 225)),
|
||||
context: (medium, Color::Rgb(230, 230, 230)),
|
||||
note: (dark, Color::Rgb(220, 220, 220)),
|
||||
interval: (medium, Color::Rgb(225, 225, 225)),
|
||||
variable: (medium, Color::Rgb(230, 230, 230)),
|
||||
vary: (dim, Color::Rgb(235, 235, 235)),
|
||||
generator: (dark, Color::Rgb(215, 215, 215)),
|
||||
default: (fg_dim, bg),
|
||||
},
|
||||
table: TableColors {
|
||||
row_even: bg,
|
||||
row_odd: surface,
|
||||
},
|
||||
values: ValuesColors {
|
||||
tempo: dark,
|
||||
value: fg_dim,
|
||||
},
|
||||
hint: HintColors {
|
||||
key: dark,
|
||||
text: fg_muted,
|
||||
},
|
||||
view_badge: ViewBadgeColors { bg: fg, fg: bg },
|
||||
nav: NavColors {
|
||||
selected_bg: Color::Rgb(200, 200, 200),
|
||||
selected_fg: fg,
|
||||
unselected_bg: surface,
|
||||
unselected_fg: fg_muted,
|
||||
},
|
||||
editor_widget: EditorWidgetColors {
|
||||
cursor_bg: fg,
|
||||
cursor_fg: bg,
|
||||
selection_bg: Color::Rgb(200, 200, 200),
|
||||
completion_bg: surface,
|
||||
completion_fg: fg,
|
||||
completion_selected: dark,
|
||||
completion_example: medium,
|
||||
},
|
||||
browser: BrowserColors {
|
||||
directory: medium,
|
||||
project_file: dark,
|
||||
selected: dark,
|
||||
file: fg,
|
||||
focused_border: dark,
|
||||
unfocused_border: fg_muted,
|
||||
root: fg,
|
||||
file_icon: fg_muted,
|
||||
folder_icon: medium,
|
||||
empty_text: fg_muted,
|
||||
},
|
||||
input: InputColors {
|
||||
text: dark,
|
||||
cursor: fg,
|
||||
hint: fg_muted,
|
||||
},
|
||||
search: SearchColors {
|
||||
active: dark,
|
||||
inactive: fg_muted,
|
||||
match_bg: dark,
|
||||
match_fg: bg,
|
||||
},
|
||||
markdown: MarkdownColors {
|
||||
h1: dark,
|
||||
h2: medium,
|
||||
h3: dim,
|
||||
code: medium,
|
||||
code_border: Color::Rgb(200, 200, 200),
|
||||
link: dark,
|
||||
link_url: dim,
|
||||
quote: fg_muted,
|
||||
text: fg,
|
||||
list: fg,
|
||||
},
|
||||
engine: EngineColors {
|
||||
header: dark,
|
||||
header_focused: dark,
|
||||
divider: Color::Rgb(210, 210, 210),
|
||||
scroll_indicator: Color::Rgb(180, 180, 180),
|
||||
label: dim,
|
||||
label_focused: medium,
|
||||
label_dim: light,
|
||||
value: fg,
|
||||
focused: dark,
|
||||
normal: fg,
|
||||
dim: light,
|
||||
path: dim,
|
||||
border_magenta: medium,
|
||||
border_green: medium,
|
||||
border_cyan: medium,
|
||||
separator: Color::Rgb(210, 210, 210),
|
||||
hint_active: dark,
|
||||
hint_inactive: lighter,
|
||||
},
|
||||
dict: DictColors {
|
||||
word_name: dark,
|
||||
word_bg: Color::Rgb(230, 230, 230),
|
||||
alias: fg_muted,
|
||||
stack_sig: medium,
|
||||
description: fg,
|
||||
example: dim,
|
||||
category_focused: dark,
|
||||
category_selected: medium,
|
||||
category_normal: fg,
|
||||
category_dimmed: light,
|
||||
border_focused: dark,
|
||||
border_normal: lighter,
|
||||
header_desc: dim,
|
||||
},
|
||||
title: TitleColors {
|
||||
big_title: dark,
|
||||
author: medium,
|
||||
link: medium,
|
||||
license: dim,
|
||||
prompt: dim,
|
||||
subtitle: fg,
|
||||
},
|
||||
meter: MeterColors {
|
||||
low: dim,
|
||||
mid: medium,
|
||||
high: dark,
|
||||
low_rgb: (140, 140, 140),
|
||||
mid_rgb: (80, 80, 80),
|
||||
high_rgb: (0, 0, 0),
|
||||
},
|
||||
sparkle: SparkleColors {
|
||||
colors: [
|
||||
(0, 0, 0),
|
||||
(60, 60, 60),
|
||||
(100, 100, 100),
|
||||
(40, 40, 40),
|
||||
(80, 80, 80),
|
||||
],
|
||||
},
|
||||
confirm: ConfirmColors {
|
||||
border: dark,
|
||||
button_selected_bg: dark,
|
||||
button_selected_fg: bg,
|
||||
},
|
||||
}
|
||||
}
|
||||
276
crates/ratatui/src/theme/monokai.rs
Normal file
276
crates/ratatui/src/theme/monokai.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
use super::*;
|
||||
use ratatui::style::Color;
|
||||
|
||||
pub fn theme() -> ThemeColors {
|
||||
let bg = Color::Rgb(39, 40, 34);
|
||||
let bg_light = Color::Rgb(53, 54, 47);
|
||||
let bg_lighter = Color::Rgb(70, 71, 62);
|
||||
let fg = Color::Rgb(248, 248, 242);
|
||||
let fg_dim = Color::Rgb(190, 190, 180);
|
||||
let comment = Color::Rgb(117, 113, 94);
|
||||
let pink = Color::Rgb(249, 38, 114);
|
||||
let green = Color::Rgb(166, 226, 46);
|
||||
let yellow = Color::Rgb(230, 219, 116);
|
||||
let blue = Color::Rgb(102, 217, 239);
|
||||
let purple = Color::Rgb(174, 129, 255);
|
||||
let orange = Color::Rgb(253, 151, 31);
|
||||
|
||||
let darker_bg = Color::Rgb(30, 31, 26);
|
||||
|
||||
ThemeColors {
|
||||
ui: UiColors {
|
||||
bg,
|
||||
bg_rgb: (39, 40, 34),
|
||||
text_primary: fg,
|
||||
text_muted: fg_dim,
|
||||
text_dim: comment,
|
||||
border: bg_lighter,
|
||||
header: blue,
|
||||
unfocused: comment,
|
||||
accent: pink,
|
||||
surface: bg_light,
|
||||
},
|
||||
status: StatusColors {
|
||||
playing_bg: Color::Rgb(50, 65, 40),
|
||||
playing_fg: green,
|
||||
stopped_bg: Color::Rgb(70, 40, 55),
|
||||
stopped_fg: pink,
|
||||
fill_on: green,
|
||||
fill_off: comment,
|
||||
fill_bg: bg_light,
|
||||
},
|
||||
selection: SelectionColors {
|
||||
cursor_bg: pink,
|
||||
cursor_fg: bg,
|
||||
selected_bg: Color::Rgb(85, 70, 80),
|
||||
selected_fg: pink,
|
||||
in_range_bg: Color::Rgb(70, 65, 70),
|
||||
in_range_fg: fg,
|
||||
cursor: pink,
|
||||
selected: Color::Rgb(85, 70, 80),
|
||||
in_range: Color::Rgb(70, 65, 70),
|
||||
},
|
||||
tile: TileColors {
|
||||
playing_active_bg: Color::Rgb(90, 65, 45),
|
||||
playing_active_fg: orange,
|
||||
playing_inactive_bg: Color::Rgb(80, 75, 50),
|
||||
playing_inactive_fg: yellow,
|
||||
active_bg: Color::Rgb(55, 75, 70),
|
||||
active_fg: blue,
|
||||
inactive_bg: bg_light,
|
||||
inactive_fg: fg_dim,
|
||||
active_selected_bg: Color::Rgb(85, 65, 80),
|
||||
active_in_range_bg: Color::Rgb(70, 65, 70),
|
||||
link_bright: [
|
||||
(249, 38, 114),
|
||||
(174, 129, 255),
|
||||
(253, 151, 31),
|
||||
(102, 217, 239),
|
||||
(166, 226, 46),
|
||||
],
|
||||
link_dim: [
|
||||
(90, 40, 60),
|
||||
(70, 55, 90),
|
||||
(85, 60, 35),
|
||||
(50, 75, 85),
|
||||
(60, 80, 40),
|
||||
],
|
||||
},
|
||||
header: HeaderColors {
|
||||
tempo_bg: Color::Rgb(75, 50, 65),
|
||||
tempo_fg: pink,
|
||||
bank_bg: Color::Rgb(50, 70, 75),
|
||||
bank_fg: blue,
|
||||
pattern_bg: Color::Rgb(55, 75, 50),
|
||||
pattern_fg: green,
|
||||
stats_bg: bg_light,
|
||||
stats_fg: fg_dim,
|
||||
},
|
||||
modal: ModalColors {
|
||||
border: blue,
|
||||
border_accent: pink,
|
||||
border_warn: orange,
|
||||
border_dim: comment,
|
||||
confirm: orange,
|
||||
rename: purple,
|
||||
input: blue,
|
||||
editor: blue,
|
||||
preview: comment,
|
||||
},
|
||||
flash: FlashColors {
|
||||
error_bg: Color::Rgb(75, 40, 55),
|
||||
error_fg: pink,
|
||||
success_bg: Color::Rgb(50, 70, 45),
|
||||
success_fg: green,
|
||||
info_bg: bg_light,
|
||||
info_fg: fg,
|
||||
event_rgb: (70, 55, 70),
|
||||
},
|
||||
list: ListColors {
|
||||
playing_bg: Color::Rgb(50, 70, 45),
|
||||
playing_fg: green,
|
||||
staged_play_bg: Color::Rgb(70, 55, 80),
|
||||
staged_play_fg: purple,
|
||||
staged_stop_bg: Color::Rgb(80, 45, 60),
|
||||
staged_stop_fg: pink,
|
||||
edit_bg: Color::Rgb(50, 70, 70),
|
||||
edit_fg: blue,
|
||||
hover_bg: bg_lighter,
|
||||
hover_fg: fg,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: pink,
|
||||
connected: green,
|
||||
listening: yellow,
|
||||
},
|
||||
syntax: SyntaxColors {
|
||||
gap_bg: darker_bg,
|
||||
executed_bg: Color::Rgb(55, 50, 55),
|
||||
selected_bg: Color::Rgb(85, 75, 50),
|
||||
emit: (fg, Color::Rgb(85, 55, 65)),
|
||||
number: (purple, Color::Rgb(60, 50, 75)),
|
||||
string: (yellow, Color::Rgb(70, 65, 45)),
|
||||
comment: (comment, darker_bg),
|
||||
keyword: (pink, Color::Rgb(80, 45, 60)),
|
||||
stack_op: (blue, Color::Rgb(50, 70, 75)),
|
||||
operator: (pink, Color::Rgb(80, 45, 60)),
|
||||
sound: (blue, Color::Rgb(50, 70, 75)),
|
||||
param: (orange, Color::Rgb(80, 60, 40)),
|
||||
context: (orange, Color::Rgb(80, 60, 40)),
|
||||
note: (green, Color::Rgb(55, 75, 45)),
|
||||
interval: (Color::Rgb(180, 235, 80), Color::Rgb(55, 75, 40)),
|
||||
variable: (green, Color::Rgb(55, 75, 45)),
|
||||
vary: (yellow, Color::Rgb(70, 65, 45)),
|
||||
generator: (blue, Color::Rgb(50, 70, 70)),
|
||||
default: (fg_dim, darker_bg),
|
||||
},
|
||||
table: TableColors {
|
||||
row_even: darker_bg,
|
||||
row_odd: bg,
|
||||
},
|
||||
values: ValuesColors {
|
||||
tempo: orange,
|
||||
value: fg_dim,
|
||||
},
|
||||
hint: HintColors {
|
||||
key: orange,
|
||||
text: comment,
|
||||
},
|
||||
view_badge: ViewBadgeColors { bg: fg, fg: bg },
|
||||
nav: NavColors {
|
||||
selected_bg: Color::Rgb(80, 60, 75),
|
||||
selected_fg: fg,
|
||||
unselected_bg: bg_light,
|
||||
unselected_fg: comment,
|
||||
},
|
||||
editor_widget: EditorWidgetColors {
|
||||
cursor_bg: fg,
|
||||
cursor_fg: bg,
|
||||
selection_bg: Color::Rgb(75, 70, 75),
|
||||
completion_bg: bg_light,
|
||||
completion_fg: fg,
|
||||
completion_selected: orange,
|
||||
completion_example: blue,
|
||||
},
|
||||
browser: BrowserColors {
|
||||
directory: blue,
|
||||
project_file: purple,
|
||||
selected: orange,
|
||||
file: fg,
|
||||
focused_border: orange,
|
||||
unfocused_border: comment,
|
||||
root: fg,
|
||||
file_icon: comment,
|
||||
folder_icon: blue,
|
||||
empty_text: comment,
|
||||
},
|
||||
input: InputColors {
|
||||
text: blue,
|
||||
cursor: fg,
|
||||
hint: comment,
|
||||
},
|
||||
search: SearchColors {
|
||||
active: orange,
|
||||
inactive: comment,
|
||||
match_bg: yellow,
|
||||
match_fg: bg,
|
||||
},
|
||||
markdown: MarkdownColors {
|
||||
h1: blue,
|
||||
h2: orange,
|
||||
h3: purple,
|
||||
code: green,
|
||||
code_border: Color::Rgb(85, 85, 75),
|
||||
link: pink,
|
||||
link_url: Color::Rgb(130, 125, 115),
|
||||
quote: comment,
|
||||
text: fg,
|
||||
list: fg,
|
||||
},
|
||||
engine: EngineColors {
|
||||
header: blue,
|
||||
header_focused: yellow,
|
||||
divider: Color::Rgb(80, 80, 72),
|
||||
scroll_indicator: Color::Rgb(95, 95, 88),
|
||||
label: Color::Rgb(150, 145, 135),
|
||||
label_focused: Color::Rgb(180, 175, 165),
|
||||
label_dim: Color::Rgb(120, 115, 105),
|
||||
value: Color::Rgb(210, 205, 195),
|
||||
focused: yellow,
|
||||
normal: fg,
|
||||
dim: Color::Rgb(95, 95, 88),
|
||||
path: Color::Rgb(150, 145, 135),
|
||||
border_magenta: pink,
|
||||
border_green: green,
|
||||
border_cyan: blue,
|
||||
separator: Color::Rgb(80, 80, 72),
|
||||
hint_active: Color::Rgb(220, 200, 100),
|
||||
hint_inactive: Color::Rgb(80, 80, 72),
|
||||
},
|
||||
dict: DictColors {
|
||||
word_name: green,
|
||||
word_bg: Color::Rgb(55, 65, 60),
|
||||
alias: comment,
|
||||
stack_sig: purple,
|
||||
description: fg,
|
||||
example: Color::Rgb(150, 145, 135),
|
||||
category_focused: yellow,
|
||||
category_selected: blue,
|
||||
category_normal: fg,
|
||||
category_dimmed: Color::Rgb(95, 95, 88),
|
||||
border_focused: yellow,
|
||||
border_normal: Color::Rgb(80, 80, 72),
|
||||
header_desc: Color::Rgb(170, 165, 155),
|
||||
},
|
||||
title: TitleColors {
|
||||
big_title: pink,
|
||||
author: blue,
|
||||
link: green,
|
||||
license: orange,
|
||||
prompt: Color::Rgb(170, 165, 155),
|
||||
subtitle: fg,
|
||||
},
|
||||
meter: MeterColors {
|
||||
low: green,
|
||||
mid: yellow,
|
||||
high: pink,
|
||||
low_rgb: (155, 215, 45),
|
||||
mid_rgb: (220, 210, 105),
|
||||
high_rgb: (240, 50, 110),
|
||||
},
|
||||
sparkle: SparkleColors {
|
||||
colors: [
|
||||
(102, 217, 239),
|
||||
(253, 151, 31),
|
||||
(166, 226, 46),
|
||||
(249, 38, 114),
|
||||
(174, 129, 255),
|
||||
],
|
||||
},
|
||||
confirm: ConfirmColors {
|
||||
border: orange,
|
||||
button_selected_bg: orange,
|
||||
button_selected_fg: bg,
|
||||
},
|
||||
}
|
||||
}
|
||||
279
crates/ratatui/src/theme/nord.rs
Normal file
279
crates/ratatui/src/theme/nord.rs
Normal file
@@ -0,0 +1,279 @@
|
||||
use super::*;
|
||||
use ratatui::style::Color;
|
||||
|
||||
pub fn theme() -> ThemeColors {
|
||||
let polar_night0 = Color::Rgb(46, 52, 64);
|
||||
let polar_night1 = Color::Rgb(59, 66, 82);
|
||||
let polar_night2 = Color::Rgb(67, 76, 94);
|
||||
let polar_night3 = Color::Rgb(76, 86, 106);
|
||||
let snow_storm0 = Color::Rgb(216, 222, 233);
|
||||
let snow_storm2 = Color::Rgb(236, 239, 244);
|
||||
let frost0 = Color::Rgb(143, 188, 187);
|
||||
let frost1 = Color::Rgb(136, 192, 208);
|
||||
let frost2 = Color::Rgb(129, 161, 193);
|
||||
let aurora_red = Color::Rgb(191, 97, 106);
|
||||
let aurora_orange = Color::Rgb(208, 135, 112);
|
||||
let aurora_yellow = Color::Rgb(235, 203, 139);
|
||||
let aurora_green = Color::Rgb(163, 190, 140);
|
||||
let aurora_purple = Color::Rgb(180, 142, 173);
|
||||
|
||||
ThemeColors {
|
||||
ui: UiColors {
|
||||
bg: polar_night0,
|
||||
bg_rgb: (46, 52, 64),
|
||||
text_primary: snow_storm2,
|
||||
text_muted: snow_storm0,
|
||||
text_dim: polar_night3,
|
||||
border: polar_night2,
|
||||
header: frost1,
|
||||
unfocused: polar_night3,
|
||||
accent: frost1,
|
||||
surface: polar_night1,
|
||||
},
|
||||
status: StatusColors {
|
||||
playing_bg: Color::Rgb(50, 65, 60),
|
||||
playing_fg: aurora_green,
|
||||
stopped_bg: Color::Rgb(65, 50, 55),
|
||||
stopped_fg: aurora_red,
|
||||
fill_on: aurora_green,
|
||||
fill_off: polar_night3,
|
||||
fill_bg: polar_night1,
|
||||
},
|
||||
selection: SelectionColors {
|
||||
cursor_bg: frost1,
|
||||
cursor_fg: polar_night0,
|
||||
selected_bg: Color::Rgb(70, 85, 105),
|
||||
selected_fg: frost1,
|
||||
in_range_bg: Color::Rgb(60, 70, 90),
|
||||
in_range_fg: snow_storm0,
|
||||
cursor: frost1,
|
||||
selected: Color::Rgb(70, 85, 105),
|
||||
in_range: Color::Rgb(60, 70, 90),
|
||||
},
|
||||
tile: TileColors {
|
||||
playing_active_bg: Color::Rgb(80, 70, 65),
|
||||
playing_active_fg: aurora_orange,
|
||||
playing_inactive_bg: Color::Rgb(75, 70, 55),
|
||||
playing_inactive_fg: aurora_yellow,
|
||||
active_bg: Color::Rgb(50, 65, 65),
|
||||
active_fg: frost0,
|
||||
inactive_bg: polar_night1,
|
||||
inactive_fg: snow_storm0,
|
||||
active_selected_bg: Color::Rgb(75, 75, 95),
|
||||
active_in_range_bg: Color::Rgb(60, 70, 85),
|
||||
link_bright: [
|
||||
(136, 192, 208),
|
||||
(180, 142, 173),
|
||||
(208, 135, 112),
|
||||
(143, 188, 187),
|
||||
(163, 190, 140),
|
||||
],
|
||||
link_dim: [
|
||||
(55, 75, 85),
|
||||
(70, 60, 70),
|
||||
(75, 55, 50),
|
||||
(55, 75, 75),
|
||||
(60, 75, 55),
|
||||
],
|
||||
},
|
||||
header: HeaderColors {
|
||||
tempo_bg: Color::Rgb(65, 55, 70),
|
||||
tempo_fg: aurora_purple,
|
||||
bank_bg: Color::Rgb(45, 60, 70),
|
||||
bank_fg: frost2,
|
||||
pattern_bg: Color::Rgb(50, 65, 65),
|
||||
pattern_fg: frost0,
|
||||
stats_bg: polar_night1,
|
||||
stats_fg: snow_storm0,
|
||||
},
|
||||
modal: ModalColors {
|
||||
border: frost1,
|
||||
border_accent: aurora_purple,
|
||||
border_warn: aurora_orange,
|
||||
border_dim: polar_night3,
|
||||
confirm: aurora_orange,
|
||||
rename: aurora_purple,
|
||||
input: frost2,
|
||||
editor: frost1,
|
||||
preview: polar_night3,
|
||||
},
|
||||
flash: FlashColors {
|
||||
error_bg: Color::Rgb(65, 50, 55),
|
||||
error_fg: aurora_red,
|
||||
success_bg: Color::Rgb(50, 65, 55),
|
||||
success_fg: aurora_green,
|
||||
info_bg: polar_night1,
|
||||
info_fg: snow_storm2,
|
||||
event_rgb: (60, 55, 75),
|
||||
},
|
||||
list: ListColors {
|
||||
playing_bg: Color::Rgb(50, 65, 55),
|
||||
playing_fg: aurora_green,
|
||||
staged_play_bg: Color::Rgb(65, 55, 70),
|
||||
staged_play_fg: aurora_purple,
|
||||
staged_stop_bg: Color::Rgb(70, 55, 60),
|
||||
staged_stop_fg: aurora_red,
|
||||
edit_bg: Color::Rgb(50, 65, 65),
|
||||
edit_fg: frost0,
|
||||
hover_bg: polar_night2,
|
||||
hover_fg: snow_storm2,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: aurora_red,
|
||||
connected: aurora_green,
|
||||
listening: aurora_yellow,
|
||||
},
|
||||
syntax: SyntaxColors {
|
||||
gap_bg: polar_night1,
|
||||
executed_bg: Color::Rgb(55, 55, 70),
|
||||
selected_bg: Color::Rgb(80, 70, 55),
|
||||
emit: (snow_storm2, Color::Rgb(75, 55, 60)),
|
||||
number: (aurora_orange, Color::Rgb(65, 55, 50)),
|
||||
string: (aurora_green, Color::Rgb(50, 60, 50)),
|
||||
comment: (polar_night3, polar_night0),
|
||||
keyword: (aurora_purple, Color::Rgb(60, 50, 65)),
|
||||
stack_op: (frost2, Color::Rgb(45, 55, 70)),
|
||||
operator: (aurora_yellow, Color::Rgb(65, 60, 45)),
|
||||
sound: (frost0, Color::Rgb(45, 60, 60)),
|
||||
param: (frost1, Color::Rgb(50, 60, 70)),
|
||||
context: (aurora_orange, Color::Rgb(65, 55, 50)),
|
||||
note: (aurora_green, Color::Rgb(50, 60, 50)),
|
||||
interval: (Color::Rgb(170, 200, 150), Color::Rgb(50, 60, 45)),
|
||||
variable: (aurora_purple, Color::Rgb(60, 50, 60)),
|
||||
vary: (aurora_yellow, Color::Rgb(65, 60, 45)),
|
||||
generator: (frost0, Color::Rgb(45, 60, 55)),
|
||||
default: (snow_storm0, polar_night1),
|
||||
},
|
||||
table: TableColors {
|
||||
row_even: polar_night1,
|
||||
row_odd: polar_night0,
|
||||
},
|
||||
values: ValuesColors {
|
||||
tempo: aurora_orange,
|
||||
value: snow_storm0,
|
||||
},
|
||||
hint: HintColors {
|
||||
key: aurora_orange,
|
||||
text: polar_night3,
|
||||
},
|
||||
view_badge: ViewBadgeColors {
|
||||
bg: snow_storm2,
|
||||
fg: polar_night0,
|
||||
},
|
||||
nav: NavColors {
|
||||
selected_bg: Color::Rgb(65, 75, 95),
|
||||
selected_fg: snow_storm2,
|
||||
unselected_bg: polar_night1,
|
||||
unselected_fg: polar_night3,
|
||||
},
|
||||
editor_widget: EditorWidgetColors {
|
||||
cursor_bg: snow_storm2,
|
||||
cursor_fg: polar_night0,
|
||||
selection_bg: Color::Rgb(60, 75, 100),
|
||||
completion_bg: polar_night1,
|
||||
completion_fg: snow_storm2,
|
||||
completion_selected: aurora_orange,
|
||||
completion_example: frost0,
|
||||
},
|
||||
browser: BrowserColors {
|
||||
directory: frost2,
|
||||
project_file: aurora_purple,
|
||||
selected: aurora_orange,
|
||||
file: snow_storm2,
|
||||
focused_border: aurora_orange,
|
||||
unfocused_border: polar_night3,
|
||||
root: snow_storm2,
|
||||
file_icon: polar_night3,
|
||||
folder_icon: frost2,
|
||||
empty_text: polar_night3,
|
||||
},
|
||||
input: InputColors {
|
||||
text: frost2,
|
||||
cursor: snow_storm2,
|
||||
hint: polar_night3,
|
||||
},
|
||||
search: SearchColors {
|
||||
active: aurora_orange,
|
||||
inactive: polar_night3,
|
||||
match_bg: aurora_yellow,
|
||||
match_fg: polar_night0,
|
||||
},
|
||||
markdown: MarkdownColors {
|
||||
h1: frost2,
|
||||
h2: aurora_orange,
|
||||
h3: aurora_purple,
|
||||
code: aurora_green,
|
||||
code_border: Color::Rgb(75, 85, 100),
|
||||
link: frost0,
|
||||
link_url: Color::Rgb(100, 110, 125),
|
||||
quote: polar_night3,
|
||||
text: snow_storm2,
|
||||
list: snow_storm2,
|
||||
},
|
||||
engine: EngineColors {
|
||||
header: frost1,
|
||||
header_focused: aurora_yellow,
|
||||
divider: Color::Rgb(70, 80, 95),
|
||||
scroll_indicator: Color::Rgb(85, 95, 110),
|
||||
label: Color::Rgb(130, 140, 155),
|
||||
label_focused: Color::Rgb(160, 170, 185),
|
||||
label_dim: Color::Rgb(100, 110, 125),
|
||||
value: Color::Rgb(190, 200, 215),
|
||||
focused: aurora_yellow,
|
||||
normal: snow_storm2,
|
||||
dim: Color::Rgb(85, 95, 110),
|
||||
path: Color::Rgb(130, 140, 155),
|
||||
border_magenta: aurora_purple,
|
||||
border_green: aurora_green,
|
||||
border_cyan: frost2,
|
||||
separator: Color::Rgb(70, 80, 95),
|
||||
hint_active: Color::Rgb(200, 180, 100),
|
||||
hint_inactive: Color::Rgb(70, 80, 95),
|
||||
},
|
||||
dict: DictColors {
|
||||
word_name: aurora_green,
|
||||
word_bg: Color::Rgb(50, 60, 75),
|
||||
alias: polar_night3,
|
||||
stack_sig: aurora_purple,
|
||||
description: snow_storm2,
|
||||
example: Color::Rgb(130, 140, 155),
|
||||
category_focused: aurora_yellow,
|
||||
category_selected: frost2,
|
||||
category_normal: snow_storm2,
|
||||
category_dimmed: Color::Rgb(85, 95, 110),
|
||||
border_focused: aurora_yellow,
|
||||
border_normal: Color::Rgb(70, 80, 95),
|
||||
header_desc: Color::Rgb(150, 160, 175),
|
||||
},
|
||||
title: TitleColors {
|
||||
big_title: frost1,
|
||||
author: frost2,
|
||||
link: frost0,
|
||||
license: aurora_orange,
|
||||
prompt: Color::Rgb(150, 160, 175),
|
||||
subtitle: snow_storm2,
|
||||
},
|
||||
meter: MeterColors {
|
||||
low: aurora_green,
|
||||
mid: aurora_yellow,
|
||||
high: aurora_red,
|
||||
low_rgb: (140, 180, 130),
|
||||
mid_rgb: (220, 190, 120),
|
||||
high_rgb: (180, 90, 100),
|
||||
},
|
||||
sparkle: SparkleColors {
|
||||
colors: [
|
||||
(136, 192, 208),
|
||||
(208, 135, 112),
|
||||
(163, 190, 140),
|
||||
(180, 142, 173),
|
||||
(235, 203, 139),
|
||||
],
|
||||
},
|
||||
confirm: ConfirmColors {
|
||||
border: aurora_orange,
|
||||
button_selected_bg: aurora_orange,
|
||||
button_selected_fg: polar_night0,
|
||||
},
|
||||
}
|
||||
}
|
||||
277
crates/ratatui/src/theme/pitch_black.rs
Normal file
277
crates/ratatui/src/theme/pitch_black.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
use super::*;
|
||||
use ratatui::style::Color;
|
||||
|
||||
pub fn theme() -> ThemeColors {
|
||||
let bg = Color::Rgb(0, 0, 0);
|
||||
let surface = Color::Rgb(10, 10, 10);
|
||||
let surface2 = Color::Rgb(21, 21, 21);
|
||||
let border = Color::Rgb(40, 40, 40);
|
||||
let fg = Color::Rgb(230, 230, 230);
|
||||
let fg_dim = Color::Rgb(160, 160, 160);
|
||||
let fg_muted = Color::Rgb(100, 100, 100);
|
||||
|
||||
let red = Color::Rgb(255, 80, 80);
|
||||
let green = Color::Rgb(80, 255, 120);
|
||||
let yellow = Color::Rgb(255, 230, 80);
|
||||
let blue = Color::Rgb(80, 180, 255);
|
||||
let purple = Color::Rgb(200, 120, 255);
|
||||
let cyan = Color::Rgb(80, 230, 230);
|
||||
let orange = Color::Rgb(255, 160, 60);
|
||||
|
||||
ThemeColors {
|
||||
ui: UiColors {
|
||||
bg,
|
||||
bg_rgb: (0, 0, 0),
|
||||
text_primary: fg,
|
||||
text_muted: fg_dim,
|
||||
text_dim: fg_muted,
|
||||
border,
|
||||
header: blue,
|
||||
unfocused: fg_muted,
|
||||
accent: cyan,
|
||||
surface,
|
||||
},
|
||||
status: StatusColors {
|
||||
playing_bg: Color::Rgb(15, 35, 20),
|
||||
playing_fg: green,
|
||||
stopped_bg: Color::Rgb(40, 15, 20),
|
||||
stopped_fg: red,
|
||||
fill_on: green,
|
||||
fill_off: fg_muted,
|
||||
fill_bg: surface,
|
||||
},
|
||||
selection: SelectionColors {
|
||||
cursor_bg: cyan,
|
||||
cursor_fg: bg,
|
||||
selected_bg: Color::Rgb(40, 50, 60),
|
||||
selected_fg: cyan,
|
||||
in_range_bg: Color::Rgb(25, 35, 45),
|
||||
in_range_fg: fg,
|
||||
cursor: cyan,
|
||||
selected: Color::Rgb(40, 50, 60),
|
||||
in_range: Color::Rgb(25, 35, 45),
|
||||
},
|
||||
tile: TileColors {
|
||||
playing_active_bg: Color::Rgb(50, 35, 20),
|
||||
playing_active_fg: orange,
|
||||
playing_inactive_bg: Color::Rgb(45, 40, 15),
|
||||
playing_inactive_fg: yellow,
|
||||
active_bg: Color::Rgb(15, 40, 40),
|
||||
active_fg: cyan,
|
||||
inactive_bg: surface,
|
||||
inactive_fg: fg_dim,
|
||||
active_selected_bg: Color::Rgb(45, 40, 55),
|
||||
active_in_range_bg: Color::Rgb(30, 35, 45),
|
||||
link_bright: [
|
||||
(80, 230, 230),
|
||||
(200, 120, 255),
|
||||
(255, 160, 60),
|
||||
(80, 180, 255),
|
||||
(80, 255, 120),
|
||||
],
|
||||
link_dim: [
|
||||
(25, 60, 60),
|
||||
(50, 35, 65),
|
||||
(60, 45, 20),
|
||||
(25, 50, 70),
|
||||
(25, 65, 35),
|
||||
],
|
||||
},
|
||||
header: HeaderColors {
|
||||
tempo_bg: Color::Rgb(50, 35, 55),
|
||||
tempo_fg: purple,
|
||||
bank_bg: Color::Rgb(20, 45, 60),
|
||||
bank_fg: blue,
|
||||
pattern_bg: Color::Rgb(20, 55, 50),
|
||||
pattern_fg: cyan,
|
||||
stats_bg: surface,
|
||||
stats_fg: fg_dim,
|
||||
},
|
||||
modal: ModalColors {
|
||||
border: cyan,
|
||||
border_accent: purple,
|
||||
border_warn: orange,
|
||||
border_dim: fg_muted,
|
||||
confirm: orange,
|
||||
rename: purple,
|
||||
input: blue,
|
||||
editor: cyan,
|
||||
preview: fg_muted,
|
||||
},
|
||||
flash: FlashColors {
|
||||
error_bg: Color::Rgb(50, 15, 20),
|
||||
error_fg: red,
|
||||
success_bg: Color::Rgb(15, 45, 25),
|
||||
success_fg: green,
|
||||
info_bg: surface,
|
||||
info_fg: fg,
|
||||
event_rgb: (40, 30, 50),
|
||||
},
|
||||
list: ListColors {
|
||||
playing_bg: Color::Rgb(15, 45, 25),
|
||||
playing_fg: green,
|
||||
staged_play_bg: Color::Rgb(45, 30, 55),
|
||||
staged_play_fg: purple,
|
||||
staged_stop_bg: Color::Rgb(55, 25, 30),
|
||||
staged_stop_fg: red,
|
||||
edit_bg: Color::Rgb(15, 45, 45),
|
||||
edit_fg: cyan,
|
||||
hover_bg: surface2,
|
||||
hover_fg: fg,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: red,
|
||||
connected: green,
|
||||
listening: yellow,
|
||||
},
|
||||
syntax: SyntaxColors {
|
||||
gap_bg: bg,
|
||||
executed_bg: Color::Rgb(25, 25, 35),
|
||||
selected_bg: Color::Rgb(55, 45, 25),
|
||||
emit: (fg, Color::Rgb(50, 30, 35)),
|
||||
number: (orange, Color::Rgb(50, 35, 20)),
|
||||
string: (green, Color::Rgb(20, 45, 25)),
|
||||
comment: (fg_muted, bg),
|
||||
keyword: (purple, Color::Rgb(40, 25, 50)),
|
||||
stack_op: (blue, Color::Rgb(20, 40, 55)),
|
||||
operator: (yellow, Color::Rgb(50, 45, 20)),
|
||||
sound: (cyan, Color::Rgb(20, 45, 45)),
|
||||
param: (purple, Color::Rgb(40, 25, 50)),
|
||||
context: (orange, Color::Rgb(50, 35, 20)),
|
||||
note: (green, Color::Rgb(20, 45, 25)),
|
||||
interval: (Color::Rgb(130, 255, 150), Color::Rgb(25, 55, 35)),
|
||||
variable: (purple, Color::Rgb(40, 25, 50)),
|
||||
vary: (yellow, Color::Rgb(50, 45, 20)),
|
||||
generator: (cyan, Color::Rgb(20, 45, 40)),
|
||||
default: (fg_dim, bg),
|
||||
},
|
||||
table: TableColors {
|
||||
row_even: bg,
|
||||
row_odd: surface,
|
||||
},
|
||||
values: ValuesColors {
|
||||
tempo: orange,
|
||||
value: fg_dim,
|
||||
},
|
||||
hint: HintColors {
|
||||
key: orange,
|
||||
text: fg_muted,
|
||||
},
|
||||
view_badge: ViewBadgeColors { bg: fg, fg: bg },
|
||||
nav: NavColors {
|
||||
selected_bg: Color::Rgb(40, 45, 55),
|
||||
selected_fg: fg,
|
||||
unselected_bg: surface,
|
||||
unselected_fg: fg_muted,
|
||||
},
|
||||
editor_widget: EditorWidgetColors {
|
||||
cursor_bg: fg,
|
||||
cursor_fg: bg,
|
||||
selection_bg: Color::Rgb(40, 50, 65),
|
||||
completion_bg: surface,
|
||||
completion_fg: fg,
|
||||
completion_selected: orange,
|
||||
completion_example: cyan,
|
||||
},
|
||||
browser: BrowserColors {
|
||||
directory: blue,
|
||||
project_file: purple,
|
||||
selected: orange,
|
||||
file: fg,
|
||||
focused_border: orange,
|
||||
unfocused_border: fg_muted,
|
||||
root: fg,
|
||||
file_icon: fg_muted,
|
||||
folder_icon: blue,
|
||||
empty_text: fg_muted,
|
||||
},
|
||||
input: InputColors {
|
||||
text: blue,
|
||||
cursor: fg,
|
||||
hint: fg_muted,
|
||||
},
|
||||
search: SearchColors {
|
||||
active: orange,
|
||||
inactive: fg_muted,
|
||||
match_bg: yellow,
|
||||
match_fg: bg,
|
||||
},
|
||||
markdown: MarkdownColors {
|
||||
h1: blue,
|
||||
h2: orange,
|
||||
h3: purple,
|
||||
code: green,
|
||||
code_border: Color::Rgb(50, 50, 50),
|
||||
link: cyan,
|
||||
link_url: Color::Rgb(90, 90, 90),
|
||||
quote: fg_muted,
|
||||
text: fg,
|
||||
list: fg,
|
||||
},
|
||||
engine: EngineColors {
|
||||
header: blue,
|
||||
header_focused: yellow,
|
||||
divider: Color::Rgb(45, 45, 45),
|
||||
scroll_indicator: Color::Rgb(60, 60, 60),
|
||||
label: Color::Rgb(130, 130, 130),
|
||||
label_focused: Color::Rgb(170, 170, 170),
|
||||
label_dim: Color::Rgb(90, 90, 90),
|
||||
value: Color::Rgb(200, 200, 200),
|
||||
focused: yellow,
|
||||
normal: fg,
|
||||
dim: Color::Rgb(60, 60, 60),
|
||||
path: Color::Rgb(130, 130, 130),
|
||||
border_magenta: purple,
|
||||
border_green: green,
|
||||
border_cyan: cyan,
|
||||
separator: Color::Rgb(45, 45, 45),
|
||||
hint_active: Color::Rgb(220, 200, 80),
|
||||
hint_inactive: Color::Rgb(45, 45, 45),
|
||||
},
|
||||
dict: DictColors {
|
||||
word_name: green,
|
||||
word_bg: Color::Rgb(20, 30, 35),
|
||||
alias: fg_muted,
|
||||
stack_sig: purple,
|
||||
description: fg,
|
||||
example: Color::Rgb(130, 130, 130),
|
||||
category_focused: yellow,
|
||||
category_selected: blue,
|
||||
category_normal: fg,
|
||||
category_dimmed: Color::Rgb(60, 60, 60),
|
||||
border_focused: yellow,
|
||||
border_normal: Color::Rgb(45, 45, 45),
|
||||
header_desc: Color::Rgb(150, 150, 150),
|
||||
},
|
||||
title: TitleColors {
|
||||
big_title: cyan,
|
||||
author: blue,
|
||||
link: green,
|
||||
license: orange,
|
||||
prompt: Color::Rgb(150, 150, 150),
|
||||
subtitle: fg,
|
||||
},
|
||||
meter: MeterColors {
|
||||
low: green,
|
||||
mid: yellow,
|
||||
high: red,
|
||||
low_rgb: (70, 240, 110),
|
||||
mid_rgb: (245, 220, 75),
|
||||
high_rgb: (245, 75, 75),
|
||||
},
|
||||
sparkle: SparkleColors {
|
||||
colors: [
|
||||
(80, 230, 230),
|
||||
(255, 160, 60),
|
||||
(80, 255, 120),
|
||||
(200, 120, 255),
|
||||
(80, 180, 255),
|
||||
],
|
||||
},
|
||||
confirm: ConfirmColors {
|
||||
border: orange,
|
||||
button_selected_bg: orange,
|
||||
button_selected_fg: bg,
|
||||
},
|
||||
}
|
||||
}
|
||||
277
crates/ratatui/src/theme/rose_pine.rs
Normal file
277
crates/ratatui/src/theme/rose_pine.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
use super::*;
|
||||
use ratatui::style::Color;
|
||||
|
||||
pub fn theme() -> ThemeColors {
|
||||
let bg = Color::Rgb(25, 23, 36);
|
||||
let bg_light = Color::Rgb(33, 32, 46);
|
||||
let bg_lighter = Color::Rgb(42, 39, 63);
|
||||
let fg = Color::Rgb(224, 222, 244);
|
||||
let fg_dim = Color::Rgb(144, 140, 170);
|
||||
let muted = Color::Rgb(110, 106, 134);
|
||||
let rose = Color::Rgb(235, 111, 146);
|
||||
let gold = Color::Rgb(246, 193, 119);
|
||||
let foam = Color::Rgb(156, 207, 216);
|
||||
let iris = Color::Rgb(196, 167, 231);
|
||||
let pine = Color::Rgb(49, 116, 143);
|
||||
let subtle = Color::Rgb(235, 188, 186);
|
||||
let love = Color::Rgb(235, 111, 146);
|
||||
|
||||
let darker_bg = Color::Rgb(21, 19, 30);
|
||||
|
||||
ThemeColors {
|
||||
ui: UiColors {
|
||||
bg,
|
||||
bg_rgb: (25, 23, 36),
|
||||
text_primary: fg,
|
||||
text_muted: fg_dim,
|
||||
text_dim: muted,
|
||||
border: bg_lighter,
|
||||
header: foam,
|
||||
unfocused: muted,
|
||||
accent: rose,
|
||||
surface: bg_light,
|
||||
},
|
||||
status: StatusColors {
|
||||
playing_bg: Color::Rgb(35, 50, 55),
|
||||
playing_fg: foam,
|
||||
stopped_bg: Color::Rgb(55, 40, 50),
|
||||
stopped_fg: love,
|
||||
fill_on: foam,
|
||||
fill_off: muted,
|
||||
fill_bg: bg_light,
|
||||
},
|
||||
selection: SelectionColors {
|
||||
cursor_bg: rose,
|
||||
cursor_fg: bg,
|
||||
selected_bg: Color::Rgb(60, 50, 70),
|
||||
selected_fg: rose,
|
||||
in_range_bg: Color::Rgb(50, 45, 60),
|
||||
in_range_fg: fg,
|
||||
cursor: rose,
|
||||
selected: Color::Rgb(60, 50, 70),
|
||||
in_range: Color::Rgb(50, 45, 60),
|
||||
},
|
||||
tile: TileColors {
|
||||
playing_active_bg: Color::Rgb(65, 55, 50),
|
||||
playing_active_fg: gold,
|
||||
playing_inactive_bg: Color::Rgb(55, 55, 55),
|
||||
playing_inactive_fg: subtle,
|
||||
active_bg: Color::Rgb(35, 50, 60),
|
||||
active_fg: foam,
|
||||
inactive_bg: bg_light,
|
||||
inactive_fg: fg_dim,
|
||||
active_selected_bg: Color::Rgb(60, 50, 70),
|
||||
active_in_range_bg: Color::Rgb(50, 45, 60),
|
||||
link_bright: [
|
||||
(235, 111, 146),
|
||||
(196, 167, 231),
|
||||
(246, 193, 119),
|
||||
(156, 207, 216),
|
||||
(49, 116, 143),
|
||||
],
|
||||
link_dim: [
|
||||
(75, 45, 55),
|
||||
(60, 50, 75),
|
||||
(75, 60, 45),
|
||||
(50, 65, 70),
|
||||
(30, 50, 55),
|
||||
],
|
||||
},
|
||||
header: HeaderColors {
|
||||
tempo_bg: Color::Rgb(60, 45, 60),
|
||||
tempo_fg: iris,
|
||||
bank_bg: Color::Rgb(35, 50, 60),
|
||||
bank_fg: foam,
|
||||
pattern_bg: Color::Rgb(35, 55, 60),
|
||||
pattern_fg: pine,
|
||||
stats_bg: bg_light,
|
||||
stats_fg: fg_dim,
|
||||
},
|
||||
modal: ModalColors {
|
||||
border: foam,
|
||||
border_accent: rose,
|
||||
border_warn: gold,
|
||||
border_dim: muted,
|
||||
confirm: gold,
|
||||
rename: iris,
|
||||
input: foam,
|
||||
editor: foam,
|
||||
preview: muted,
|
||||
},
|
||||
flash: FlashColors {
|
||||
error_bg: Color::Rgb(60, 40, 50),
|
||||
error_fg: love,
|
||||
success_bg: Color::Rgb(35, 55, 55),
|
||||
success_fg: foam,
|
||||
info_bg: bg_light,
|
||||
info_fg: fg,
|
||||
event_rgb: (50, 45, 60),
|
||||
},
|
||||
list: ListColors {
|
||||
playing_bg: Color::Rgb(35, 55, 55),
|
||||
playing_fg: foam,
|
||||
staged_play_bg: Color::Rgb(55, 50, 70),
|
||||
staged_play_fg: iris,
|
||||
staged_stop_bg: Color::Rgb(60, 45, 55),
|
||||
staged_stop_fg: love,
|
||||
edit_bg: Color::Rgb(35, 50, 60),
|
||||
edit_fg: foam,
|
||||
hover_bg: bg_lighter,
|
||||
hover_fg: fg,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: love,
|
||||
connected: foam,
|
||||
listening: gold,
|
||||
},
|
||||
syntax: SyntaxColors {
|
||||
gap_bg: darker_bg,
|
||||
executed_bg: Color::Rgb(40, 40, 55),
|
||||
selected_bg: Color::Rgb(65, 55, 50),
|
||||
emit: (fg, Color::Rgb(60, 45, 60)),
|
||||
number: (iris, Color::Rgb(55, 50, 70)),
|
||||
string: (gold, Color::Rgb(65, 55, 45)),
|
||||
comment: (muted, darker_bg),
|
||||
keyword: (rose, Color::Rgb(60, 45, 55)),
|
||||
stack_op: (foam, Color::Rgb(40, 55, 60)),
|
||||
operator: (love, Color::Rgb(60, 45, 55)),
|
||||
sound: (foam, Color::Rgb(40, 55, 60)),
|
||||
param: (gold, Color::Rgb(65, 55, 45)),
|
||||
context: (gold, Color::Rgb(65, 55, 45)),
|
||||
note: (pine, Color::Rgb(35, 50, 55)),
|
||||
interval: (Color::Rgb(100, 160, 180), Color::Rgb(35, 55, 60)),
|
||||
variable: (pine, Color::Rgb(35, 50, 55)),
|
||||
vary: (subtle, Color::Rgb(60, 55, 55)),
|
||||
generator: (foam, Color::Rgb(40, 55, 60)),
|
||||
default: (fg_dim, darker_bg),
|
||||
},
|
||||
table: TableColors {
|
||||
row_even: darker_bg,
|
||||
row_odd: bg,
|
||||
},
|
||||
values: ValuesColors {
|
||||
tempo: gold,
|
||||
value: fg_dim,
|
||||
},
|
||||
hint: HintColors {
|
||||
key: gold,
|
||||
text: muted,
|
||||
},
|
||||
view_badge: ViewBadgeColors { bg: fg, fg: bg },
|
||||
nav: NavColors {
|
||||
selected_bg: Color::Rgb(60, 50, 70),
|
||||
selected_fg: fg,
|
||||
unselected_bg: bg_light,
|
||||
unselected_fg: muted,
|
||||
},
|
||||
editor_widget: EditorWidgetColors {
|
||||
cursor_bg: fg,
|
||||
cursor_fg: bg,
|
||||
selection_bg: Color::Rgb(55, 50, 70),
|
||||
completion_bg: bg_light,
|
||||
completion_fg: fg,
|
||||
completion_selected: gold,
|
||||
completion_example: foam,
|
||||
},
|
||||
browser: BrowserColors {
|
||||
directory: foam,
|
||||
project_file: iris,
|
||||
selected: gold,
|
||||
file: fg,
|
||||
focused_border: gold,
|
||||
unfocused_border: muted,
|
||||
root: fg,
|
||||
file_icon: muted,
|
||||
folder_icon: foam,
|
||||
empty_text: muted,
|
||||
},
|
||||
input: InputColors {
|
||||
text: foam,
|
||||
cursor: fg,
|
||||
hint: muted,
|
||||
},
|
||||
search: SearchColors {
|
||||
active: gold,
|
||||
inactive: muted,
|
||||
match_bg: gold,
|
||||
match_fg: bg,
|
||||
},
|
||||
markdown: MarkdownColors {
|
||||
h1: foam,
|
||||
h2: gold,
|
||||
h3: iris,
|
||||
code: pine,
|
||||
code_border: Color::Rgb(60, 55, 75),
|
||||
link: rose,
|
||||
link_url: Color::Rgb(100, 95, 120),
|
||||
quote: muted,
|
||||
text: fg,
|
||||
list: fg,
|
||||
},
|
||||
engine: EngineColors {
|
||||
header: foam,
|
||||
header_focused: gold,
|
||||
divider: Color::Rgb(55, 52, 70),
|
||||
scroll_indicator: Color::Rgb(70, 65, 90),
|
||||
label: Color::Rgb(130, 125, 155),
|
||||
label_focused: Color::Rgb(160, 155, 185),
|
||||
label_dim: Color::Rgb(100, 95, 125),
|
||||
value: Color::Rgb(200, 195, 220),
|
||||
focused: gold,
|
||||
normal: fg,
|
||||
dim: Color::Rgb(70, 65, 90),
|
||||
path: Color::Rgb(130, 125, 155),
|
||||
border_magenta: iris,
|
||||
border_green: foam,
|
||||
border_cyan: pine,
|
||||
separator: Color::Rgb(55, 52, 70),
|
||||
hint_active: Color::Rgb(230, 180, 110),
|
||||
hint_inactive: Color::Rgb(55, 52, 70),
|
||||
},
|
||||
dict: DictColors {
|
||||
word_name: pine,
|
||||
word_bg: Color::Rgb(40, 50, 55),
|
||||
alias: muted,
|
||||
stack_sig: iris,
|
||||
description: fg,
|
||||
example: Color::Rgb(130, 125, 155),
|
||||
category_focused: gold,
|
||||
category_selected: foam,
|
||||
category_normal: fg,
|
||||
category_dimmed: Color::Rgb(70, 65, 90),
|
||||
border_focused: gold,
|
||||
border_normal: Color::Rgb(55, 52, 70),
|
||||
header_desc: Color::Rgb(150, 145, 175),
|
||||
},
|
||||
title: TitleColors {
|
||||
big_title: rose,
|
||||
author: foam,
|
||||
link: pine,
|
||||
license: gold,
|
||||
prompt: Color::Rgb(150, 145, 175),
|
||||
subtitle: fg,
|
||||
},
|
||||
meter: MeterColors {
|
||||
low: foam,
|
||||
mid: gold,
|
||||
high: love,
|
||||
low_rgb: (156, 207, 216),
|
||||
mid_rgb: (246, 193, 119),
|
||||
high_rgb: (235, 111, 146),
|
||||
},
|
||||
sparkle: SparkleColors {
|
||||
colors: [
|
||||
(156, 207, 216),
|
||||
(246, 193, 119),
|
||||
(49, 116, 143),
|
||||
(235, 111, 146),
|
||||
(196, 167, 231),
|
||||
],
|
||||
},
|
||||
confirm: ConfirmColors {
|
||||
border: gold,
|
||||
button_selected_bg: gold,
|
||||
button_selected_fg: bg,
|
||||
},
|
||||
}
|
||||
}
|
||||
277
crates/ratatui/src/theme/tokyo_night.rs
Normal file
277
crates/ratatui/src/theme/tokyo_night.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
use super::*;
|
||||
use ratatui::style::Color;
|
||||
|
||||
pub fn theme() -> ThemeColors {
|
||||
let bg = Color::Rgb(26, 27, 38);
|
||||
let bg_light = Color::Rgb(36, 40, 59);
|
||||
let bg_lighter = Color::Rgb(52, 59, 88);
|
||||
let fg = Color::Rgb(169, 177, 214);
|
||||
let fg_dim = Color::Rgb(130, 140, 180);
|
||||
let comment = Color::Rgb(86, 95, 137);
|
||||
let blue = Color::Rgb(122, 162, 247);
|
||||
let purple = Color::Rgb(187, 154, 247);
|
||||
let green = Color::Rgb(158, 206, 106);
|
||||
let red = Color::Rgb(247, 118, 142);
|
||||
let orange = Color::Rgb(224, 175, 104);
|
||||
let cyan = Color::Rgb(125, 207, 255);
|
||||
let yellow = Color::Rgb(224, 175, 104);
|
||||
|
||||
let darker_bg = Color::Rgb(22, 23, 32);
|
||||
|
||||
ThemeColors {
|
||||
ui: UiColors {
|
||||
bg,
|
||||
bg_rgb: (26, 27, 38),
|
||||
text_primary: fg,
|
||||
text_muted: fg_dim,
|
||||
text_dim: comment,
|
||||
border: bg_lighter,
|
||||
header: blue,
|
||||
unfocused: comment,
|
||||
accent: purple,
|
||||
surface: bg_light,
|
||||
},
|
||||
status: StatusColors {
|
||||
playing_bg: Color::Rgb(45, 60, 50),
|
||||
playing_fg: green,
|
||||
stopped_bg: Color::Rgb(60, 40, 50),
|
||||
stopped_fg: red,
|
||||
fill_on: green,
|
||||
fill_off: comment,
|
||||
fill_bg: bg_light,
|
||||
},
|
||||
selection: SelectionColors {
|
||||
cursor_bg: purple,
|
||||
cursor_fg: bg,
|
||||
selected_bg: Color::Rgb(70, 60, 90),
|
||||
selected_fg: purple,
|
||||
in_range_bg: Color::Rgb(55, 55, 75),
|
||||
in_range_fg: fg,
|
||||
cursor: purple,
|
||||
selected: Color::Rgb(70, 60, 90),
|
||||
in_range: Color::Rgb(55, 55, 75),
|
||||
},
|
||||
tile: TileColors {
|
||||
playing_active_bg: Color::Rgb(70, 60, 45),
|
||||
playing_active_fg: orange,
|
||||
playing_inactive_bg: Color::Rgb(60, 60, 50),
|
||||
playing_inactive_fg: yellow,
|
||||
active_bg: Color::Rgb(45, 60, 75),
|
||||
active_fg: blue,
|
||||
inactive_bg: bg_light,
|
||||
inactive_fg: fg_dim,
|
||||
active_selected_bg: Color::Rgb(70, 55, 85),
|
||||
active_in_range_bg: Color::Rgb(55, 55, 75),
|
||||
link_bright: [
|
||||
(247, 118, 142),
|
||||
(187, 154, 247),
|
||||
(224, 175, 104),
|
||||
(125, 207, 255),
|
||||
(158, 206, 106),
|
||||
],
|
||||
link_dim: [
|
||||
(80, 45, 55),
|
||||
(65, 55, 85),
|
||||
(75, 60, 40),
|
||||
(45, 70, 85),
|
||||
(55, 70, 45),
|
||||
],
|
||||
},
|
||||
header: HeaderColors {
|
||||
tempo_bg: Color::Rgb(65, 50, 70),
|
||||
tempo_fg: purple,
|
||||
bank_bg: Color::Rgb(45, 55, 75),
|
||||
bank_fg: blue,
|
||||
pattern_bg: Color::Rgb(50, 65, 50),
|
||||
pattern_fg: green,
|
||||
stats_bg: bg_light,
|
||||
stats_fg: fg_dim,
|
||||
},
|
||||
modal: ModalColors {
|
||||
border: blue,
|
||||
border_accent: purple,
|
||||
border_warn: orange,
|
||||
border_dim: comment,
|
||||
confirm: orange,
|
||||
rename: purple,
|
||||
input: blue,
|
||||
editor: blue,
|
||||
preview: comment,
|
||||
},
|
||||
flash: FlashColors {
|
||||
error_bg: Color::Rgb(65, 40, 50),
|
||||
error_fg: red,
|
||||
success_bg: Color::Rgb(45, 60, 45),
|
||||
success_fg: green,
|
||||
info_bg: bg_light,
|
||||
info_fg: fg,
|
||||
event_rgb: (55, 50, 70),
|
||||
},
|
||||
list: ListColors {
|
||||
playing_bg: Color::Rgb(45, 60, 45),
|
||||
playing_fg: green,
|
||||
staged_play_bg: Color::Rgb(60, 50, 75),
|
||||
staged_play_fg: purple,
|
||||
staged_stop_bg: Color::Rgb(70, 45, 55),
|
||||
staged_stop_fg: red,
|
||||
edit_bg: Color::Rgb(45, 55, 70),
|
||||
edit_fg: blue,
|
||||
hover_bg: bg_lighter,
|
||||
hover_fg: fg,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: red,
|
||||
connected: green,
|
||||
listening: yellow,
|
||||
},
|
||||
syntax: SyntaxColors {
|
||||
gap_bg: darker_bg,
|
||||
executed_bg: Color::Rgb(45, 45, 60),
|
||||
selected_bg: Color::Rgb(70, 60, 50),
|
||||
emit: (fg, Color::Rgb(70, 50, 65)),
|
||||
number: (purple, Color::Rgb(55, 50, 70)),
|
||||
string: (green, Color::Rgb(50, 60, 50)),
|
||||
comment: (comment, darker_bg),
|
||||
keyword: (purple, Color::Rgb(60, 50, 70)),
|
||||
stack_op: (cyan, Color::Rgb(45, 60, 75)),
|
||||
operator: (red, Color::Rgb(65, 45, 55)),
|
||||
sound: (blue, Color::Rgb(45, 55, 70)),
|
||||
param: (orange, Color::Rgb(70, 55, 45)),
|
||||
context: (orange, Color::Rgb(70, 55, 45)),
|
||||
note: (green, Color::Rgb(50, 60, 45)),
|
||||
interval: (Color::Rgb(180, 220, 130), Color::Rgb(50, 65, 45)),
|
||||
variable: (green, Color::Rgb(50, 60, 45)),
|
||||
vary: (yellow, Color::Rgb(70, 60, 45)),
|
||||
generator: (cyan, Color::Rgb(45, 60, 75)),
|
||||
default: (fg_dim, darker_bg),
|
||||
},
|
||||
table: TableColors {
|
||||
row_even: darker_bg,
|
||||
row_odd: bg,
|
||||
},
|
||||
values: ValuesColors {
|
||||
tempo: orange,
|
||||
value: fg_dim,
|
||||
},
|
||||
hint: HintColors {
|
||||
key: orange,
|
||||
text: comment,
|
||||
},
|
||||
view_badge: ViewBadgeColors { bg: fg, fg: bg },
|
||||
nav: NavColors {
|
||||
selected_bg: Color::Rgb(65, 55, 80),
|
||||
selected_fg: fg,
|
||||
unselected_bg: bg_light,
|
||||
unselected_fg: comment,
|
||||
},
|
||||
editor_widget: EditorWidgetColors {
|
||||
cursor_bg: fg,
|
||||
cursor_fg: bg,
|
||||
selection_bg: Color::Rgb(60, 60, 80),
|
||||
completion_bg: bg_light,
|
||||
completion_fg: fg,
|
||||
completion_selected: orange,
|
||||
completion_example: cyan,
|
||||
},
|
||||
browser: BrowserColors {
|
||||
directory: blue,
|
||||
project_file: purple,
|
||||
selected: orange,
|
||||
file: fg,
|
||||
focused_border: orange,
|
||||
unfocused_border: comment,
|
||||
root: fg,
|
||||
file_icon: comment,
|
||||
folder_icon: blue,
|
||||
empty_text: comment,
|
||||
},
|
||||
input: InputColors {
|
||||
text: blue,
|
||||
cursor: fg,
|
||||
hint: comment,
|
||||
},
|
||||
search: SearchColors {
|
||||
active: orange,
|
||||
inactive: comment,
|
||||
match_bg: yellow,
|
||||
match_fg: bg,
|
||||
},
|
||||
markdown: MarkdownColors {
|
||||
h1: blue,
|
||||
h2: orange,
|
||||
h3: purple,
|
||||
code: green,
|
||||
code_border: Color::Rgb(70, 75, 95),
|
||||
link: red,
|
||||
link_url: Color::Rgb(110, 120, 160),
|
||||
quote: comment,
|
||||
text: fg,
|
||||
list: fg,
|
||||
},
|
||||
engine: EngineColors {
|
||||
header: blue,
|
||||
header_focused: yellow,
|
||||
divider: Color::Rgb(65, 70, 90),
|
||||
scroll_indicator: Color::Rgb(80, 85, 110),
|
||||
label: Color::Rgb(130, 140, 175),
|
||||
label_focused: Color::Rgb(160, 170, 200),
|
||||
label_dim: Color::Rgb(100, 110, 145),
|
||||
value: Color::Rgb(190, 195, 220),
|
||||
focused: yellow,
|
||||
normal: fg,
|
||||
dim: Color::Rgb(80, 85, 110),
|
||||
path: Color::Rgb(130, 140, 175),
|
||||
border_magenta: purple,
|
||||
border_green: green,
|
||||
border_cyan: cyan,
|
||||
separator: Color::Rgb(65, 70, 90),
|
||||
hint_active: Color::Rgb(210, 180, 100),
|
||||
hint_inactive: Color::Rgb(65, 70, 90),
|
||||
},
|
||||
dict: DictColors {
|
||||
word_name: green,
|
||||
word_bg: Color::Rgb(45, 55, 60),
|
||||
alias: comment,
|
||||
stack_sig: purple,
|
||||
description: fg,
|
||||
example: Color::Rgb(130, 140, 175),
|
||||
category_focused: yellow,
|
||||
category_selected: blue,
|
||||
category_normal: fg,
|
||||
category_dimmed: Color::Rgb(80, 85, 110),
|
||||
border_focused: yellow,
|
||||
border_normal: Color::Rgb(65, 70, 90),
|
||||
header_desc: Color::Rgb(150, 160, 190),
|
||||
},
|
||||
title: TitleColors {
|
||||
big_title: purple,
|
||||
author: blue,
|
||||
link: green,
|
||||
license: orange,
|
||||
prompt: Color::Rgb(150, 160, 190),
|
||||
subtitle: fg,
|
||||
},
|
||||
meter: MeterColors {
|
||||
low: green,
|
||||
mid: yellow,
|
||||
high: red,
|
||||
low_rgb: (158, 206, 106),
|
||||
mid_rgb: (224, 175, 104),
|
||||
high_rgb: (247, 118, 142),
|
||||
},
|
||||
sparkle: SparkleColors {
|
||||
colors: [
|
||||
(125, 207, 255),
|
||||
(224, 175, 104),
|
||||
(158, 206, 106),
|
||||
(247, 118, 142),
|
||||
(187, 154, 247),
|
||||
],
|
||||
},
|
||||
confirm: ConfirmColors {
|
||||
border: orange,
|
||||
button_selected_bg: orange,
|
||||
button_selected_fg: bg,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::theme::meter;
|
||||
use crate::theme;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
@@ -30,13 +30,13 @@ impl VuMeter {
|
||||
(db - DB_MIN) / DB_RANGE
|
||||
}
|
||||
|
||||
fn row_to_color(row_position: f32) -> Color {
|
||||
fn row_to_color(row_position: f32, colors: &theme::ThemeColors) -> Color {
|
||||
if row_position > 0.9 {
|
||||
meter::HIGH
|
||||
colors.meter.high
|
||||
} else if row_position > 0.75 {
|
||||
meter::MID
|
||||
colors.meter.mid
|
||||
} else {
|
||||
meter::LOW
|
||||
colors.meter.low
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ impl Widget for VuMeter {
|
||||
return;
|
||||
}
|
||||
|
||||
let colors = theme::get();
|
||||
let height = area.height as usize;
|
||||
let half_width = area.width / 2;
|
||||
let gap = 1u16;
|
||||
@@ -62,7 +63,7 @@ impl Widget for VuMeter {
|
||||
for row in 0..height {
|
||||
let y = area.y + area.height - 1 - row as u16;
|
||||
let row_position = (row as f32 + 0.5) / height as f32;
|
||||
let color = Self::row_to_color(row_position);
|
||||
let color = Self::row_to_color(row_position, &colors);
|
||||
|
||||
for col in 0..half_width.saturating_sub(gap) {
|
||||
let x = area.x + col;
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# Audio Engine
|
||||
|
||||
Cagire uses **Doux** as its audio engine ([https://doux.livecoding.fr](https://doux.livecoding.fr)). Doux is a standalone synthesis and sampling engine that receives commands as strings and turns them into sound. Doux is a fixed graph synthesizer, which means that the structure of the sound is defined by a fixed set of nodes and connections, and the parameters of these nodes can be adjusted to create different sounds. Doux is extremely versatile and you are likely to find it useful for a wide range of musical styles and genres.
|
||||
|
||||
## How Sound is Produced
|
||||
|
||||
When the sequencer hits an active step, the Forth script is compiled and executed. Each emit operation (`.`) generates a command string that is sent to Doux. The command encodes the sound name and all accumulated parameters. The following example script:
|
||||
|
||||
```
|
||||
"saw" sound c4 note 0.5 gain 2000 lpf .
|
||||
```
|
||||
|
||||
will produce a command string that Doux interprets to _play a saw wave at C4 with gain 0.5 and a 2kHz lowpass filter_.
|
||||
|
||||
## Sound sources
|
||||
|
||||
Each sound needs a source. Sources are defined by typing their name followed by the `sound` keyword. Sources are raw waveforms or samples. They are shaped by passing additional parameters that will modify the characteristics of the sound: envelopes, effects, synthesis options, etc. The following example defines a source named `saw` with a frequency of 440 Hz, a gain of 0.5 and some reverb:
|
||||
|
||||
```
|
||||
"saw" source 440 freq 0.5 gain 0.5 verb .
|
||||
```
|
||||
|
||||
The audio engine offers a vast array (~20+) of sources including oscillators, noises, live input, and more.
|
||||
|
||||
## Settings
|
||||
|
||||
- **Channels**: Output channel count (1-64)
|
||||
- **Buffer Size**: Audio buffer in samples (64-4096). Lower values reduce latency but increase CPU load.
|
||||
- **Voices**: Maximum polyphony (1-128, default 32). When the limit is reached, the oldest voice is stolen.
|
||||
|
||||
Settings are persisted across sessions.
|
||||
|
||||
## Samples
|
||||
|
||||
Cagire scans sample directories recursively and indexes all audio files. Add sample paths on the Engine page with **a**, remove with **d**. Use samples in scripts by name:
|
||||
|
||||
```
|
||||
"kick" s .
|
||||
"hat" s 0.5 gain .
|
||||
```
|
||||
|
||||
The sample index is shown on the Engine page with the total count.
|
||||
|
||||
## Visualizers
|
||||
|
||||
The Engine page displays two real-time visualizers:
|
||||
|
||||
- **Scope**: Waveform display (64 samples), updated on every audio callback
|
||||
- **Spectrum**: 32-band FFT analyzer with logarithmic frequency scaling (20Hz to Nyquist), Hann window, displayed in dB
|
||||
|
||||
Both can be toggled on or off in the Options page.
|
||||
|
||||
## Monitoring
|
||||
|
||||
The Engine page shows live metrics:
|
||||
|
||||
- **Active voices**: Current polyphony count
|
||||
- **Peak voices**: Highest voice count since last reset (press **r** to reset)
|
||||
- **CPU load**: Audio thread utilization
|
||||
- **Events**: Total emitted and dropped event counts
|
||||
|
||||
## Tempo Scaling
|
||||
|
||||
Some parameters are automatically scaled by step duration so they sound consistent across tempos. These include envelope times (attack, decay, release), filter envelopes, pitch envelopes, FM envelopes, glide, and reverb/delay times.
|
||||
|
||||
## Commands
|
||||
|
||||
On the Engine page:
|
||||
|
||||
- **h**: Hush (graceful fade-out of all voices)
|
||||
- **p**: Panic (hard stop all voices immediately)
|
||||
- **r**: Reset peak voice counter
|
||||
- **t**: Test sound (plays a 440Hz sine)
|
||||
@@ -10,6 +10,7 @@ The dictionary shows every available word organized by category:
|
||||
- **Arithmetic**: Math operations.
|
||||
- **Sound**: Sound sources and emission.
|
||||
- **Filter**, **Envelope**, **Effects**: Sound shaping.
|
||||
- **MIDI**: External MIDI control (`chan`, `cc`, `emit`, `clock`, etc.).
|
||||
- **Context**: Sequencer state like `step`, `beat`, `tempo`.
|
||||
- And many more...
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Effects
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# Keybindings
|
||||
|
||||
## Navigation
|
||||
|
||||
- **Ctrl+Left/Right**: Switch between pages (Main, Audio, Doc)
|
||||
- **q**: Quit (with confirmation)
|
||||
|
||||
## Main Page - Sequencer Focus
|
||||
|
||||
- **Arrow keys**: Navigate steps in pattern
|
||||
- **Enter**: Toggle step active/inactive
|
||||
- **Tab**: Switch focus to editor
|
||||
- **Space**: Play/pause
|
||||
|
||||
### Pattern Controls
|
||||
|
||||
- **< / >**: Decrease/increase pattern length
|
||||
- **[ / ]**: Decrease/increase pattern speed
|
||||
- **p**: Open pattern picker
|
||||
- **b**: Open bank picker
|
||||
|
||||
### Slots
|
||||
|
||||
- **1-8**: Toggle slot on/off
|
||||
- **g**: Queue current pattern to first free slot
|
||||
- **G**: Queue removal of current pattern from its slot
|
||||
|
||||
### Files
|
||||
|
||||
- **s**: Save project
|
||||
- **l**: Load project
|
||||
- **Ctrl+C**: Copy step script
|
||||
- **Ctrl+V**: Paste step script
|
||||
|
||||
### Execution
|
||||
|
||||
- **Ctrl+R**: Run current step's script immediately (one-shot)
|
||||
|
||||
### Tempo
|
||||
|
||||
- **+ / =**: Increase tempo
|
||||
- **-**: Decrease tempo
|
||||
|
||||
## Main Page - Editor Focus
|
||||
|
||||
- **Tab / Esc**: Return to sequencer focus
|
||||
- **Ctrl+E**: Compile current step script
|
||||
- **Ctrl+R**: Run script in editor immediately (one-shot)
|
||||
|
||||
## Audio Page
|
||||
|
||||
- **h**: Hush (stop all sounds gracefully)
|
||||
- **p**: Panic (kill all sounds immediately)
|
||||
- **r**: Reset peak voice counter
|
||||
- **t**: Test sound (plays 440Hz sine)
|
||||
- **Space**: Play/pause
|
||||
|
||||
## Doc Page
|
||||
|
||||
- **j / Down**: Next topic
|
||||
- **k / Up**: Previous topic
|
||||
- **PgDn**: Scroll content down
|
||||
- **PgUp**: Scroll content up
|
||||
68
docs/midi_input.md
Normal file
68
docs/midi_input.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# MIDI Input
|
||||
|
||||
Read incoming MIDI control change values with the `ccval` word. This lets you use hardware controllers to modulate parameters in your scripts.
|
||||
|
||||
## Reading CC Values
|
||||
|
||||
The `ccval` word takes a CC number and channel from the stack, and returns the last received value:
|
||||
|
||||
```forth
|
||||
1 1 ccval ;; read CC 1 (mod wheel) on channel 1
|
||||
```
|
||||
|
||||
Stack effect: `(cc chan -- val)`
|
||||
|
||||
The returned value is `0`-`127`. If no message has been received for that CC/channel combination, the value is `0`.
|
||||
|
||||
## Device Selection
|
||||
|
||||
Use `dev` to select which input device slot to read from:
|
||||
|
||||
```forth
|
||||
1 dev 1 1 ccval ;; read from device slot 1
|
||||
```
|
||||
|
||||
Device defaults to `0` if not specified.
|
||||
|
||||
## Practical Examples
|
||||
|
||||
Map a controller knob to filter cutoff:
|
||||
|
||||
```forth
|
||||
74 1 ccval 127 / 200 2740 range lpf
|
||||
```
|
||||
|
||||
Use mod wheel for vibrato depth:
|
||||
|
||||
```forth
|
||||
1 1 ccval 127 / 0 0.5 range vibdepth
|
||||
```
|
||||
|
||||
Crossfade between two sounds:
|
||||
|
||||
```forth
|
||||
1 1 ccval 127 / ;; normalize to 0.0-1.0
|
||||
dup saw s swap gain .
|
||||
1 swap - tri s swap gain .
|
||||
```
|
||||
|
||||
## Scaling Values
|
||||
|
||||
CC values are integers `0`-`127`. Normalize to `0.0`-`1.0` first, then use `range` to scale:
|
||||
|
||||
```forth
|
||||
;; normalize to 0.0-1.0
|
||||
74 1 ccval 127 /
|
||||
|
||||
;; scale to custom range (e.g., 200-4000)
|
||||
74 1 ccval 127 / 200 4000 range
|
||||
|
||||
;; bipolar range (-1.0 to 1.0)
|
||||
74 1 ccval 127 / -1 1 range
|
||||
```
|
||||
|
||||
The `range` word takes a normalized value (`0.0`-`1.0`) and scales it to your target range: `(val min max -- scaled)`.
|
||||
|
||||
## Latency
|
||||
|
||||
CC values are sampled at the start of each step. Changes during a step take effect on the next step. For smoothest results, turn knobs slowly or use higher step rates.
|
||||
26
docs/midi_intro.md
Normal file
26
docs/midi_intro.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# MIDI
|
||||
|
||||
Cagire speaks MIDI. You can send notes, control changes, and other messages to external synthesizers, drum machines, and DAWs. You can also read incoming control change values from MIDI controllers and use them to modulate your scripts.
|
||||
|
||||
## Device Slots
|
||||
|
||||
Cagire provides four input slots and four output slots, numbered `0` through `3`. Each slot can connect to one MIDI device. By default, slot `0` is used for both input and output.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure your MIDI devices in the **Options** view. Select input and output devices for each slot. Changes take effect immediately.
|
||||
|
||||
## MIDI vs Audio
|
||||
|
||||
The audio engine (`Doux`) and MIDI are independent systems. Use `.` to emit audio commands, use `m.` to emit MIDI messages. You can use both in the same script:
|
||||
|
||||
```forth
|
||||
saw s c4 note 0.5 gain . ;; audio
|
||||
60 note 100 velocity m. ;; MIDI
|
||||
```
|
||||
|
||||
MIDI is useful when you want to sequence external gear, layer Cagire with hardware synths, or integrate into a larger studio setup. The audio engine is self-contained and needs no external equipment.
|
||||
|
||||
## Clock and Transport
|
||||
|
||||
Cagire can send MIDI clock and transport messages to synchronize external gear. Use `mclock` to send a single clock pulse, and `mstart`, `mstop`, `mcont` for transport control. MIDI clock requires 24 pulses per quarter note, so you need to call `mclock` at the appropriate rate for your tempo.
|
||||
92
docs/midi_output.md
Normal file
92
docs/midi_output.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# MIDI Output
|
||||
|
||||
Send MIDI messages using the `m.` word. Build up parameters on the stack, then emit. The system determines message type based on which parameters you set.
|
||||
|
||||
## Note Messages
|
||||
|
||||
The default message type is a note. Set `note` and `velocity`, then emit:
|
||||
|
||||
```forth
|
||||
60 note 100 velocity m. ;; middle C, velocity 100
|
||||
c4 note 80 velocity m. ;; same pitch, lower velocity
|
||||
```
|
||||
|
||||
| Parameter | Stack | Range | Description |
|
||||
|-----------|-------|-------|-------------|
|
||||
| `note` | `(n --)` | 0-127 | MIDI note number |
|
||||
| `velocity` | `(n --)` | 0-127 | Note velocity |
|
||||
| `chan` | `(n --)` | 1-16 | MIDI channel |
|
||||
| `dur` | `(f --)` | steps | Note duration |
|
||||
| `dev` | `(n --)` | 0-3 | Output device slot |
|
||||
|
||||
Duration (`dur`) is measured in steps. If not set, the note plays until the next step. Channel defaults to `1`, device defaults to `0`.
|
||||
|
||||
## Control Change
|
||||
|
||||
Set both `ccnum` (controller number) and `ccout` (value) to send a CC message:
|
||||
|
||||
```forth
|
||||
74 ccnum 64 ccout m. ;; CC 74, value 64
|
||||
1 ccnum 127 ccout m. ;; mod wheel full
|
||||
```
|
||||
|
||||
| Parameter | Stack | Range | Description |
|
||||
|-----------|-------|-------|-------------|
|
||||
| `ccnum` | `(n --)` | 0-127 | Controller number |
|
||||
| `ccout` | `(n --)` | 0-127 | Controller value |
|
||||
|
||||
## Pitch Bend
|
||||
|
||||
Set `bend` to send pitch bend. The range is `-1.0` (full down) to `1.0` (full up), with `0.0` as center:
|
||||
|
||||
```forth
|
||||
0.5 bend m. ;; bend up halfway
|
||||
-1.0 bend m. ;; full bend down
|
||||
```
|
||||
|
||||
## Channel Pressure
|
||||
|
||||
Set `pressure` to send channel aftertouch:
|
||||
|
||||
```forth
|
||||
64 pressure m. ;; medium pressure
|
||||
```
|
||||
|
||||
## Program Change
|
||||
|
||||
Set `program` to send a program change message:
|
||||
|
||||
```forth
|
||||
0 program m. ;; select program 0
|
||||
127 program m. ;; select program 127
|
||||
```
|
||||
|
||||
## Message Priority
|
||||
|
||||
When multiple message types are set, only one is sent per emit. Priority order:
|
||||
|
||||
1. Control Change (if `ccnum` AND `ccout` set)
|
||||
2. Pitch Bend
|
||||
3. Channel Pressure
|
||||
4. Program Change
|
||||
5. Note (default)
|
||||
|
||||
To send multiple message types, use multiple emits:
|
||||
|
||||
```forth
|
||||
74 ccnum 100 ccout m. ;; CC first
|
||||
60 note 100 velocity m. ;; then note
|
||||
```
|
||||
|
||||
## Real-Time Messages
|
||||
|
||||
Transport and clock messages for external synchronization:
|
||||
|
||||
| Word | Description |
|
||||
|------|-------------|
|
||||
| `mclock` | Send MIDI clock pulse |
|
||||
| `mstart` | Send MIDI start |
|
||||
| `mstop` | Send MIDI stop |
|
||||
| `mcont` | Send MIDI continue |
|
||||
|
||||
These ignore all parameters and send immediately.
|
||||
@@ -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.
|
||||
|
||||
Three cycling words exist:
|
||||
Two cycling words exist:
|
||||
|
||||
- `cycle` - selects based on `runs` (how many times this step has played)
|
||||
- `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.
|
||||
|
||||
`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
|
||||
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
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Parameters
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
# Sequencer
|
||||
|
||||
## Structure
|
||||
|
||||
The sequencer is organized into:
|
||||
|
||||
- **Banks**: 16 banks (B01-B16)
|
||||
- **Patterns**: 16 patterns per bank (P01-P16)
|
||||
- **Steps**: Up to 32 steps per pattern
|
||||
- **Slots**: 8 concurrent playback slots
|
||||
|
||||
## Patterns
|
||||
|
||||
Each pattern has:
|
||||
|
||||
- **Length**: Number of steps (1-32)
|
||||
- **Speed**: Playback rate relative to tempo
|
||||
- **Steps**: Each step can have a script
|
||||
|
||||
### Speed Settings
|
||||
|
||||
- 1/4: Quarter speed
|
||||
- 1/2: Half speed
|
||||
- 1x: Normal speed
|
||||
- 2x: Double speed
|
||||
- 4x: Quadruple speed
|
||||
|
||||
## Slots
|
||||
|
||||
Slots allow multiple patterns to play simultaneously.
|
||||
|
||||
- Press **1-8** to toggle a slot
|
||||
- Slot changes are quantized to the next bar
|
||||
- A "?" indicates a slot queued to start
|
||||
- A "x" indicates a slot queued to stop
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Edit a pattern in the main view
|
||||
2. Press **g** to queue it to the first free slot
|
||||
3. It starts playing at the next bar boundary
|
||||
4. Press **G** to queue its removal
|
||||
|
||||
## Steps
|
||||
|
||||
Steps are the basic unit of the sequencer:
|
||||
|
||||
- Navigate with arrow keys
|
||||
- Toggle active with Enter
|
||||
- Each step can contain a Rhai script
|
||||
|
||||
### Active vs Inactive
|
||||
|
||||
- Active steps (highlighted) execute their script
|
||||
- Inactive steps are skipped during playback
|
||||
- Toggle with Enter key
|
||||
|
||||
## Playback
|
||||
|
||||
The sequencer uses Ableton Link for timing:
|
||||
|
||||
- Syncs with other Link-enabled apps
|
||||
- Bar boundaries are used for slot changes
|
||||
- Phase shows position within the current bar
|
||||
|
||||
## Files
|
||||
|
||||
Projects are saved as JSON files:
|
||||
|
||||
- **s**: Save with dialog
|
||||
- **l**: Load with dialog
|
||||
- File extension: `.buboseq`
|
||||
@@ -1,2 +0,0 @@
|
||||
# Sound Basics
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Tempo & Speed
|
||||
|
||||
6
release.toml
Normal file
6
release.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
allow-branch = ["main"]
|
||||
sign-commit = false
|
||||
sign-tag = false
|
||||
push = true
|
||||
publish = false
|
||||
tag-name = "v{{version}}"
|
||||
439
src/app.rs
439
src/app.rs
@@ -10,12 +10,13 @@ use crate::commands::AppCommand;
|
||||
use crate::engine::{
|
||||
LinkState, PatternChange, PatternSnapshot, SeqCommand, SequencerSnapshot, StepSnapshot,
|
||||
};
|
||||
use crate::midi::MidiState;
|
||||
use crate::model::{self, Bank, Dictionary, Pattern, Rng, ScriptEngine, StepContext, Variables};
|
||||
use crate::page::Page;
|
||||
use crate::services::pattern_editor;
|
||||
use crate::settings::Settings;
|
||||
use crate::state::{
|
||||
AudioSettings, DictFocus, EditorContext, FlashKind, Focus, LiveKeyState, Metrics, Modal,
|
||||
AudioSettings, CyclicEnum, DictFocus, EditorContext, FlashKind, LiveKeyState, Metrics, Modal,
|
||||
OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, PlaybackState,
|
||||
ProjectState, StagedChange, UiState,
|
||||
};
|
||||
@@ -46,6 +47,13 @@ pub struct App {
|
||||
pub audio: AudioSettings,
|
||||
pub options: OptionsState,
|
||||
pub panel: PanelState,
|
||||
pub midi: MidiState,
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -80,6 +88,7 @@ impl App {
|
||||
audio: AudioSettings::default(),
|
||||
options: OptionsState::default(),
|
||||
panel: PanelState::default(),
|
||||
midi: MidiState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +117,30 @@ impl App {
|
||||
tempo: link.tempo(),
|
||||
quantum: link.quantum(),
|
||||
},
|
||||
midi: crate::settings::MidiSettings {
|
||||
output_devices: {
|
||||
let outputs = crate::midi::list_midi_outputs();
|
||||
self.midi
|
||||
.selected_outputs
|
||||
.iter()
|
||||
.map(|opt| {
|
||||
opt.and_then(|idx| outputs.get(idx).map(|d| d.name.clone()))
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
},
|
||||
input_devices: {
|
||||
let inputs = crate::midi::list_midi_inputs();
|
||||
self.midi
|
||||
.selected_inputs
|
||||
.iter()
|
||||
.map(|opt| {
|
||||
opt.and_then(|idx| inputs.get(idx).map(|d| d.name.clone()))
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
},
|
||||
},
|
||||
};
|
||||
settings.save();
|
||||
}
|
||||
@@ -116,6 +149,21 @@ impl App {
|
||||
(self.editor_ctx.bank, self.editor_ctx.pattern)
|
||||
}
|
||||
|
||||
fn selected_steps(&self) -> Vec<usize> {
|
||||
match self.editor_ctx.selection_range() {
|
||||
Some(range) => range.collect(),
|
||||
None => vec![self.editor_ctx.step],
|
||||
}
|
||||
}
|
||||
|
||||
fn annotate_copy_name(name: &Option<String>) -> Option<String> {
|
||||
match name {
|
||||
Some(n) if !n.ends_with(" (copy)") => Some(format!("{n} (copy)")),
|
||||
Some(n) => Some(n.clone()),
|
||||
None => Some("(copy)".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_all_patterns_dirty(&mut self) {
|
||||
self.project_state.mark_all_dirty();
|
||||
}
|
||||
@@ -134,20 +182,6 @@ impl App {
|
||||
link.set_tempo((current - 1.0).max(20.0));
|
||||
}
|
||||
|
||||
pub fn toggle_focus(&mut self, link: &LinkState) {
|
||||
match self.editor_ctx.focus {
|
||||
Focus::Sequencer => {
|
||||
self.editor_ctx.focus = Focus::Editor;
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
Focus::Editor => {
|
||||
self.save_editor_to_step();
|
||||
self.compile_current_step(link);
|
||||
self.editor_ctx.focus = Focus::Sequencer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_edit_pattern(&self) -> &Pattern {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
self.project_state.project.pattern_at(bank, pattern)
|
||||
@@ -193,17 +227,8 @@ impl App {
|
||||
|
||||
pub fn toggle_steps(&mut self) {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
let indices: Vec<usize> = match self.editor_ctx.selection_range() {
|
||||
Some(range) => range.collect(),
|
||||
None => vec![self.editor_ctx.step],
|
||||
};
|
||||
for idx in indices {
|
||||
pattern_editor::toggle_step(
|
||||
&mut self.project_state.project,
|
||||
bank,
|
||||
pattern,
|
||||
idx,
|
||||
);
|
||||
for idx in self.selected_steps() {
|
||||
pattern_editor::toggle_step(&mut self.project_state.project, bank, pattern, idx);
|
||||
}
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
}
|
||||
@@ -238,6 +263,37 @@ impl App {
|
||||
self.project_state.mark_dirty(change.bank, change.pattern);
|
||||
}
|
||||
|
||||
fn create_step_context(&self, step_idx: usize, link: &LinkState) -> StepContext {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
let speed = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at(bank, pattern)
|
||||
.speed
|
||||
.multiplier();
|
||||
StepContext {
|
||||
step: step_idx,
|
||||
beat: link.beat(),
|
||||
bank,
|
||||
pattern,
|
||||
tempo: link.tempo(),
|
||||
phase: link.phase(),
|
||||
slot: 0,
|
||||
runs: 0,
|
||||
iter: 0,
|
||||
speed,
|
||||
fill: false,
|
||||
nudge_secs: 0.0,
|
||||
cc_access: None,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_x: 0.5,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_y: 0.5,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_down: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_step_to_editor(&mut self) {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
if let Some(script) = pattern_editor::get_step_script(
|
||||
@@ -287,30 +343,7 @@ impl App {
|
||||
link: &LinkState,
|
||||
audio_tx: &arc_swap::ArcSwap<Sender<crate::engine::AudioCommand>>,
|
||||
) -> Result<(), String> {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
let step_idx = self.editor_ctx.step;
|
||||
let speed = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at(bank, pattern)
|
||||
.speed
|
||||
.multiplier();
|
||||
|
||||
let ctx = StepContext {
|
||||
step: step_idx,
|
||||
beat: link.beat(),
|
||||
bank,
|
||||
pattern,
|
||||
tempo: link.tempo(),
|
||||
phase: link.phase(),
|
||||
slot: 0,
|
||||
runs: 0,
|
||||
iter: 0,
|
||||
speed,
|
||||
fill: false,
|
||||
nudge_secs: 0.0,
|
||||
};
|
||||
|
||||
let ctx = self.create_step_context(self.editor_ctx.step, link);
|
||||
let cmds = self.script_engine.evaluate(script, &ctx)?;
|
||||
for cmd in cmds {
|
||||
let _ = audio_tx
|
||||
@@ -340,26 +373,7 @@ impl App {
|
||||
return;
|
||||
}
|
||||
|
||||
let speed = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at(bank, pattern)
|
||||
.speed
|
||||
.multiplier();
|
||||
let ctx = StepContext {
|
||||
step: step_idx,
|
||||
beat: link.beat(),
|
||||
bank,
|
||||
pattern,
|
||||
tempo: link.tempo(),
|
||||
phase: link.phase(),
|
||||
slot: 0,
|
||||
runs: 0,
|
||||
iter: 0,
|
||||
speed,
|
||||
fill: false,
|
||||
nudge_secs: 0.0,
|
||||
};
|
||||
let ctx = self.create_step_context(step_idx, link);
|
||||
|
||||
match self.script_engine.evaluate(&script, &ctx) {
|
||||
Ok(cmds) => {
|
||||
@@ -417,26 +431,7 @@ impl App {
|
||||
continue;
|
||||
}
|
||||
|
||||
let speed = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at(bank, pattern)
|
||||
.speed
|
||||
.multiplier();
|
||||
let ctx = StepContext {
|
||||
step: step_idx,
|
||||
beat: 0.0,
|
||||
bank,
|
||||
pattern,
|
||||
tempo: link.tempo(),
|
||||
phase: 0.0,
|
||||
slot: 0,
|
||||
runs: 0,
|
||||
iter: 0,
|
||||
speed,
|
||||
fill: false,
|
||||
nudge_secs: 0.0,
|
||||
};
|
||||
let ctx = self.create_step_context(step_idx, link);
|
||||
|
||||
if let Ok(cmds) = self.script_engine.evaluate(&script, &ctx) {
|
||||
if let Some(step) = self
|
||||
@@ -532,13 +527,19 @@ impl App {
|
||||
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.project_state.project.sample_paths = self.audio.config.sample_paths.clone();
|
||||
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) {
|
||||
Ok(final_path) => {
|
||||
self.ui.set_status(format!("Saved: {}", final_path.display()));
|
||||
self.ui
|
||||
.set_status(format!("Saved: {}", final_path.display()));
|
||||
self.project_state.file_path = Some(final_path);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -551,12 +552,27 @@ impl App {
|
||||
match model::load(&path) {
|
||||
Ok(project) => {
|
||||
let tempo = project.tempo;
|
||||
let playing = project.playing_patterns.clone();
|
||||
|
||||
self.project_state.project = project;
|
||||
self.editor_ctx.step = 0;
|
||||
self.load_step_to_editor();
|
||||
self.compile_all_steps(link);
|
||||
self.mark_all_patterns_dirty();
|
||||
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.project_state.file_path = Some(path);
|
||||
}
|
||||
@@ -671,11 +687,7 @@ impl App {
|
||||
pub fn paste_pattern(&mut self, bank: usize, pattern: usize) {
|
||||
if let Some(src) = &self.copied_pattern {
|
||||
let mut pat = src.clone();
|
||||
pat.name = match &src.name {
|
||||
Some(name) if !name.ends_with(" (copy)") => Some(format!("{name} (copy)")),
|
||||
Some(name) => Some(name.clone()),
|
||||
None => Some("(copy)".to_string()),
|
||||
};
|
||||
pat.name = Self::annotate_copy_name(&src.name);
|
||||
self.project_state.project.banks[bank].patterns[pattern] = pat;
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern {
|
||||
@@ -694,11 +706,7 @@ impl App {
|
||||
pub fn paste_bank(&mut self, bank: usize) {
|
||||
if let Some(src) = &self.copied_bank {
|
||||
let mut b = src.clone();
|
||||
b.name = match &src.name {
|
||||
Some(name) if !name.ends_with(" (copy)") => Some(format!("{name} (copy)")),
|
||||
Some(name) => Some(name.clone()),
|
||||
None => Some("(copy)".to_string()),
|
||||
};
|
||||
b.name = Self::annotate_copy_name(&src.name);
|
||||
self.project_state.project.banks[bank] = b;
|
||||
for pattern in 0..self.project_state.project.banks[bank].patterns.len() {
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
@@ -712,10 +720,7 @@ impl App {
|
||||
|
||||
pub fn harden_steps(&mut self) {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
let indices: Vec<usize> = match self.editor_ctx.selection_range() {
|
||||
Some(range) => range.collect(),
|
||||
None => vec![self.editor_ctx.step],
|
||||
};
|
||||
let indices = self.selected_steps();
|
||||
|
||||
let pat = self.project_state.project.pattern_at(bank, pattern);
|
||||
let resolutions: Vec<(usize, String)> = indices
|
||||
@@ -752,18 +757,15 @@ impl App {
|
||||
if count == 1 {
|
||||
self.ui.flash("Step hardened", 150, FlashKind::Success);
|
||||
} else {
|
||||
self.ui.flash(&format!("{count} steps hardened"), 150, FlashKind::Success);
|
||||
self.ui
|
||||
.flash(&format!("{count} steps hardened"), 150, FlashKind::Success);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn copy_steps(&mut self) {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
let pat = self.project_state.project.pattern_at(bank, pattern);
|
||||
|
||||
let indices: Vec<usize> = match self.editor_ctx.selection_range() {
|
||||
Some(range) => range.collect(),
|
||||
None => vec![self.editor_ctx.step],
|
||||
};
|
||||
let indices = self.selected_steps();
|
||||
|
||||
let mut steps = Vec::new();
|
||||
let mut scripts = Vec::new();
|
||||
@@ -792,7 +794,8 @@ impl App {
|
||||
let _ = clip.set_text(scripts.join("\n"));
|
||||
}
|
||||
|
||||
self.ui.flash(&format!("Copied {count} steps"), 150, FlashKind::Info);
|
||||
self.ui
|
||||
.flash(&format!("Copied {count} steps"), 150, FlashKind::Info);
|
||||
}
|
||||
|
||||
pub fn paste_steps(&mut self, link: &LinkState) {
|
||||
@@ -811,7 +814,12 @@ impl App {
|
||||
if target >= pat_len {
|
||||
break;
|
||||
}
|
||||
if let Some(step) = self.project_state.project.pattern_at_mut(bank, pattern).step_mut(target) {
|
||||
if let Some(step) = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at_mut(bank, pattern)
|
||||
.step_mut(target)
|
||||
{
|
||||
let source = if same_pattern { data.source } else { None };
|
||||
step.active = data.active;
|
||||
step.source = source;
|
||||
@@ -841,7 +849,11 @@ impl App {
|
||||
}
|
||||
|
||||
self.editor_ctx.clear_selection();
|
||||
self.ui.flash(&format!("Pasted {} steps", copied.steps.len()), 150, FlashKind::Success);
|
||||
self.ui.flash(
|
||||
&format!("Pasted {} steps", copied.steps.len()),
|
||||
150,
|
||||
FlashKind::Success,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn link_paste_steps(&mut self) {
|
||||
@@ -853,7 +865,8 @@ impl App {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
|
||||
if copied.bank != bank || copied.pattern != pattern {
|
||||
self.ui.set_status("Can only link within same pattern".to_string());
|
||||
self.ui
|
||||
.set_status("Can only link within same pattern".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -874,7 +887,12 @@ impl App {
|
||||
if source_idx == Some(target) {
|
||||
continue;
|
||||
}
|
||||
if let Some(step) = self.project_state.project.pattern_at_mut(bank, pattern).step_mut(target) {
|
||||
if let Some(step) = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at_mut(bank, pattern)
|
||||
.step_mut(target)
|
||||
{
|
||||
step.source = source_idx;
|
||||
step.script.clear();
|
||||
step.command = None;
|
||||
@@ -884,17 +902,18 @@ impl App {
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
self.load_step_to_editor();
|
||||
self.editor_ctx.clear_selection();
|
||||
self.ui.flash(&format!("Linked {} steps", copied.steps.len()), 150, FlashKind::Success);
|
||||
self.ui.flash(
|
||||
&format!("Linked {} steps", copied.steps.len()),
|
||||
150,
|
||||
FlashKind::Success,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn duplicate_steps(&mut self, link: &LinkState) {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
let pat = self.project_state.project.pattern_at(bank, pattern);
|
||||
let pat_len = pat.length;
|
||||
let indices: Vec<usize> = match self.editor_ctx.selection_range() {
|
||||
Some(range) => range.collect(),
|
||||
None => vec![self.editor_ctx.step],
|
||||
};
|
||||
let indices = self.selected_steps();
|
||||
let count = indices.len();
|
||||
let paste_at = *indices.last().unwrap() + 1;
|
||||
|
||||
@@ -914,7 +933,12 @@ impl App {
|
||||
if target >= pat_len {
|
||||
break;
|
||||
}
|
||||
if let Some(step) = self.project_state.project.pattern_at_mut(bank, pattern).step_mut(target) {
|
||||
if let Some(step) = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at_mut(bank, pattern)
|
||||
.step_mut(target)
|
||||
{
|
||||
step.active = active;
|
||||
step.source = source;
|
||||
if source.is_some() {
|
||||
@@ -940,7 +964,11 @@ impl App {
|
||||
}
|
||||
|
||||
self.editor_ctx.clear_selection();
|
||||
self.ui.flash(&format!("Duplicated {count} steps"), 150, FlashKind::Success);
|
||||
self.ui.flash(
|
||||
&format!("Duplicated {count} steps"),
|
||||
150,
|
||||
FlashKind::Success,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn open_pattern_modal(&mut self, field: PatternField) {
|
||||
@@ -980,9 +1008,6 @@ impl App {
|
||||
AppCommand::PrevStep => self.prev_step(),
|
||||
AppCommand::StepUp => self.step_up(),
|
||||
AppCommand::StepDown => self.step_down(),
|
||||
AppCommand::ToggleFocus => self.toggle_focus(link),
|
||||
AppCommand::SelectEditBank(bank) => self.select_edit_bank(bank),
|
||||
AppCommand::SelectEditPattern(pattern) => self.select_edit_pattern(pattern),
|
||||
|
||||
// Pattern editing
|
||||
AppCommand::ToggleSteps => self.toggle_steps(),
|
||||
@@ -1026,7 +1051,6 @@ impl App {
|
||||
// Script editing
|
||||
AppCommand::SaveEditorToStep => self.save_editor_to_step(),
|
||||
AppCommand::CompileCurrentStep => self.compile_current_step(link),
|
||||
AppCommand::CompileAllSteps => self.compile_all_steps(link),
|
||||
AppCommand::DeleteStep {
|
||||
bank,
|
||||
pattern,
|
||||
@@ -1068,9 +1092,6 @@ impl App {
|
||||
AppCommand::DuplicateSteps => self.duplicate_steps(link),
|
||||
|
||||
// Pattern playback (staging)
|
||||
AppCommand::StagePatternToggle { bank, pattern } => {
|
||||
self.stage_pattern_toggle(bank, pattern, snapshot);
|
||||
}
|
||||
AppCommand::CommitStagedChanges => {
|
||||
self.commit_staged_changes();
|
||||
}
|
||||
@@ -1095,22 +1116,19 @@ impl App {
|
||||
step,
|
||||
name,
|
||||
} => {
|
||||
if let Some(s) = self.project_state.project.banks[bank].patterns[pattern].step_mut(step) {
|
||||
if let Some(s) =
|
||||
self.project_state.project.banks[bank].patterns[pattern].step_mut(step)
|
||||
{
|
||||
s.name = name;
|
||||
}
|
||||
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),
|
||||
|
||||
// UI
|
||||
AppCommand::SetStatus(msg) => self.ui.set_status(msg),
|
||||
AppCommand::ClearStatus => self.ui.clear_status(),
|
||||
AppCommand::Flash {
|
||||
message,
|
||||
duration_ms,
|
||||
kind,
|
||||
} => self.ui.flash(&message, duration_ms, kind),
|
||||
AppCommand::OpenModal(modal) => {
|
||||
if matches!(modal, Modal::Editor) {
|
||||
// If current step is a shallow copy, navigate to source step
|
||||
@@ -1217,18 +1235,18 @@ impl App {
|
||||
AppCommand::DictNextCategory => {
|
||||
let count = dict_view::category_count();
|
||||
self.ui.dict_category = (self.ui.dict_category + 1) % count;
|
||||
self.ui.dict_scroll = 0;
|
||||
}
|
||||
AppCommand::DictPrevCategory => {
|
||||
let count = dict_view::category_count();
|
||||
self.ui.dict_category = (self.ui.dict_category + count - 1) % count;
|
||||
self.ui.dict_scroll = 0;
|
||||
}
|
||||
AppCommand::DictScrollDown(n) => {
|
||||
self.ui.dict_scroll = self.ui.dict_scroll.saturating_add(n);
|
||||
let s = self.ui.dict_scroll_mut();
|
||||
*s = s.saturating_add(n);
|
||||
}
|
||||
AppCommand::DictScrollUp(n) => {
|
||||
self.ui.dict_scroll = self.ui.dict_scroll.saturating_sub(n);
|
||||
let s = self.ui.dict_scroll_mut();
|
||||
*s = s.saturating_sub(n);
|
||||
}
|
||||
AppCommand::DictActivateSearch => {
|
||||
self.ui.dict_search_active = true;
|
||||
@@ -1237,15 +1255,15 @@ impl App {
|
||||
AppCommand::DictClearSearch => {
|
||||
self.ui.dict_search_query.clear();
|
||||
self.ui.dict_search_active = false;
|
||||
self.ui.dict_scroll = 0;
|
||||
*self.ui.dict_scroll_mut() = 0;
|
||||
}
|
||||
AppCommand::DictSearchInput(c) => {
|
||||
self.ui.dict_search_query.push(c);
|
||||
self.ui.dict_scroll = 0;
|
||||
*self.ui.dict_scroll_mut() = 0;
|
||||
}
|
||||
AppCommand::DictSearchBackspace => {
|
||||
self.ui.dict_search_query.pop();
|
||||
self.ui.dict_scroll = 0;
|
||||
*self.ui.dict_scroll_mut() = 0;
|
||||
}
|
||||
AppCommand::DictSearchConfirm => {
|
||||
self.ui.dict_search_active = false;
|
||||
@@ -1279,6 +1297,137 @@ impl App {
|
||||
let pattern = self.patterns_nav.selected_pattern();
|
||||
self.stage_pattern_toggle(bank, pattern, snapshot);
|
||||
}
|
||||
|
||||
// UI state
|
||||
AppCommand::ClearMinimap => {
|
||||
self.ui.minimap_until = None;
|
||||
}
|
||||
AppCommand::HideTitle => {
|
||||
self.ui.show_title = false;
|
||||
}
|
||||
AppCommand::ToggleEditorStack => {
|
||||
self.editor_ctx.show_stack = !self.editor_ctx.show_stack;
|
||||
}
|
||||
AppCommand::SetColorScheme(scheme) => {
|
||||
self.ui.color_scheme = scheme;
|
||||
crate::theme::set(scheme.to_theme());
|
||||
}
|
||||
AppCommand::ToggleRuntimeHighlight => {
|
||||
self.ui.runtime_highlight = !self.ui.runtime_highlight;
|
||||
}
|
||||
AppCommand::ToggleCompletion => {
|
||||
self.ui.show_completion = !self.ui.show_completion;
|
||||
self.editor_ctx
|
||||
.editor
|
||||
.set_completion_enabled(self.ui.show_completion);
|
||||
}
|
||||
AppCommand::AdjustFlashBrightness(delta) => {
|
||||
self.ui.flash_brightness = (self.ui.flash_brightness + delta).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
// Live keys
|
||||
AppCommand::ToggleLiveKeysFill => {
|
||||
self.live_keys.flip_fill();
|
||||
}
|
||||
|
||||
// Panel
|
||||
AppCommand::ClosePanel => {
|
||||
self.panel.visible = false;
|
||||
self.panel.focus = crate::state::PanelFocus::Main;
|
||||
}
|
||||
|
||||
// Selection
|
||||
AppCommand::SetSelectionAnchor(step) => {
|
||||
self.editor_ctx.selection_anchor = Some(step);
|
||||
}
|
||||
|
||||
// Audio settings (engine page)
|
||||
AppCommand::AudioNextSection => {
|
||||
self.audio.next_section();
|
||||
}
|
||||
AppCommand::AudioPrevSection => {
|
||||
self.audio.prev_section();
|
||||
}
|
||||
AppCommand::AudioOutputListUp => {
|
||||
self.audio.output_list.move_up();
|
||||
}
|
||||
AppCommand::AudioOutputListDown(count) => {
|
||||
self.audio.output_list.move_down(count);
|
||||
}
|
||||
AppCommand::AudioOutputPageUp => {
|
||||
self.audio.output_list.page_up();
|
||||
}
|
||||
AppCommand::AudioOutputPageDown(count) => {
|
||||
self.audio.output_list.page_down(count);
|
||||
}
|
||||
AppCommand::AudioInputListUp => {
|
||||
self.audio.input_list.move_up();
|
||||
}
|
||||
AppCommand::AudioInputListDown(count) => {
|
||||
self.audio.input_list.move_down(count);
|
||||
}
|
||||
AppCommand::AudioInputPageDown(count) => {
|
||||
self.audio.input_list.page_down(count);
|
||||
}
|
||||
AppCommand::AudioSettingNext => {
|
||||
self.audio.setting_kind = self.audio.setting_kind.next();
|
||||
}
|
||||
AppCommand::AudioSettingPrev => {
|
||||
self.audio.setting_kind = self.audio.setting_kind.prev();
|
||||
}
|
||||
AppCommand::SetOutputDevice(name) => {
|
||||
self.audio.config.output_device = Some(name);
|
||||
}
|
||||
AppCommand::SetInputDevice(name) => {
|
||||
self.audio.config.input_device = Some(name);
|
||||
}
|
||||
AppCommand::SetDeviceKind(kind) => {
|
||||
self.audio.device_kind = kind;
|
||||
}
|
||||
AppCommand::AdjustAudioSetting { setting, delta } => {
|
||||
use crate::state::SettingKind;
|
||||
match setting {
|
||||
SettingKind::Channels => self.audio.adjust_channels(delta as i16),
|
||||
SettingKind::BufferSize => self.audio.adjust_buffer_size(delta),
|
||||
SettingKind::Polyphony => self.audio.adjust_max_voices(delta),
|
||||
SettingKind::Nudge => {
|
||||
self.metrics.nudge_ms =
|
||||
(self.metrics.nudge_ms + delta as f64).clamp(-50.0, 50.0);
|
||||
}
|
||||
SettingKind::Lookahead => self.audio.adjust_lookahead(delta),
|
||||
}
|
||||
}
|
||||
AppCommand::AudioTriggerRestart => {
|
||||
self.audio.trigger_restart();
|
||||
}
|
||||
AppCommand::RemoveLastSamplePath => {
|
||||
self.audio.remove_last_sample_path();
|
||||
}
|
||||
AppCommand::AudioRefreshDevices => {
|
||||
self.audio.refresh_devices();
|
||||
}
|
||||
|
||||
// Options page
|
||||
AppCommand::OptionsNextFocus => {
|
||||
self.options.next_focus();
|
||||
}
|
||||
AppCommand::OptionsPrevFocus => {
|
||||
self.options.prev_focus();
|
||||
}
|
||||
AppCommand::ToggleRefreshRate => {
|
||||
self.audio.toggle_refresh_rate();
|
||||
}
|
||||
AppCommand::ToggleScope => {
|
||||
self.audio.config.show_scope = !self.audio.config.show_scope;
|
||||
}
|
||||
AppCommand::ToggleSpectrum => {
|
||||
self.audio.config.show_spectrum = !self.audio.config.show_spectrum;
|
||||
}
|
||||
|
||||
// Metrics
|
||||
AppCommand::ResetPeakVoices => {
|
||||
self.metrics.peak_voices = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,25 +8,24 @@ use eframe::NativeOptions;
|
||||
use egui_ratatui::RataguiBackend;
|
||||
use ratatui::Terminal;
|
||||
use soft_ratatui::embedded_graphics_unicodefonts::{
|
||||
mono_6x13_atlas, mono_6x13_bold_atlas, mono_6x13_italic_atlas,
|
||||
mono_7x13_atlas, mono_7x13_bold_atlas, mono_7x13_italic_atlas,
|
||||
mono_8x13_atlas, mono_8x13_bold_atlas, mono_8x13_italic_atlas,
|
||||
mono_9x15_atlas, mono_9x15_bold_atlas,
|
||||
mono_10x20_atlas, mono_6x13_atlas, mono_6x13_bold_atlas, mono_6x13_italic_atlas,
|
||||
mono_7x13_atlas, mono_7x13_bold_atlas, mono_7x13_italic_atlas, mono_8x13_atlas,
|
||||
mono_8x13_bold_atlas, mono_8x13_italic_atlas, mono_9x15_atlas, mono_9x15_bold_atlas,
|
||||
mono_9x18_atlas, mono_9x18_bold_atlas,
|
||||
mono_10x20_atlas,
|
||||
};
|
||||
use soft_ratatui::{EmbeddedGraphics, SoftBackend};
|
||||
|
||||
use cagire::app::App;
|
||||
use cagire::engine::{
|
||||
build_stream, spawn_sequencer, AnalysisHandle, AudioStreamConfig, LinkState, ScopeBuffer,
|
||||
SequencerConfig, SequencerHandle, SpectrumBuffer,
|
||||
build_stream, spawn_sequencer, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand,
|
||||
ScopeBuffer, SequencerConfig, SequencerHandle, SpectrumBuffer,
|
||||
};
|
||||
use cagire::input::{handle_key, InputContext, InputResult};
|
||||
use cagire::input_egui::convert_egui_events;
|
||||
use cagire::settings::Settings;
|
||||
use cagire::state::audio::RefreshRate;
|
||||
use cagire::views;
|
||||
use crossbeam_channel::Receiver;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "cagire-desktop", about = "Cagire desktop application")]
|
||||
@@ -144,7 +143,11 @@ struct CagireDesktop {
|
||||
sample_rate_shared: Arc<AtomicU32>,
|
||||
_stream: Option<cpal::Stream>,
|
||||
_analysis_handle: Option<AnalysisHandle>,
|
||||
midi_rx: Receiver<MidiCommand>,
|
||||
current_font: FontChoice,
|
||||
mouse_x: Arc<AtomicU32>,
|
||||
mouse_y: Arc<AtomicU32>,
|
||||
mouse_down: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl CagireDesktop {
|
||||
@@ -201,13 +204,23 @@ impl CagireDesktop {
|
||||
initial_samples.extend(index);
|
||||
}
|
||||
|
||||
let mouse_x = Arc::new(AtomicU32::new(0.5_f32.to_bits()));
|
||||
let mouse_y = Arc::new(AtomicU32::new(0.5_f32.to_bits()));
|
||||
let mouse_down = Arc::new(AtomicU32::new(0.0_f32.to_bits()));
|
||||
|
||||
let seq_config = SequencerConfig {
|
||||
audio_sample_pos: Arc::clone(&audio_sample_pos),
|
||||
sample_rate: Arc::clone(&sample_rate_shared),
|
||||
lookahead_ms: Arc::clone(&lookahead_ms),
|
||||
cc_access: Some(
|
||||
Arc::new(app.midi.cc_memory.clone()) as Arc<dyn cagire::model::CcAccess>
|
||||
),
|
||||
mouse_x: Arc::clone(&mouse_x),
|
||||
mouse_y: Arc::clone(&mouse_y),
|
||||
mouse_down: Arc::clone(&mouse_down),
|
||||
};
|
||||
|
||||
let (sequencer, initial_audio_rx) = spawn_sequencer(
|
||||
let (sequencer, initial_audio_rx, midi_rx) = spawn_sequencer(
|
||||
Arc::clone(&link),
|
||||
Arc::clone(&playing),
|
||||
Arc::clone(&app.variables),
|
||||
@@ -268,7 +281,11 @@ impl CagireDesktop {
|
||||
sample_rate_shared,
|
||||
_stream: stream,
|
||||
_analysis_handle: analysis_handle,
|
||||
midi_rx,
|
||||
current_font,
|
||||
mouse_x,
|
||||
mouse_y,
|
||||
mouse_down,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +302,7 @@ impl CagireDesktop {
|
||||
return;
|
||||
};
|
||||
let new_audio_rx = sequencer.swap_audio_channel();
|
||||
self.midi_rx = sequencer.swap_midi_channel();
|
||||
|
||||
let new_config = AudioStreamConfig {
|
||||
output_device: self.app.audio.config.output_device.clone(),
|
||||
@@ -331,7 +349,11 @@ impl CagireDesktop {
|
||||
|
||||
self.app.metrics.active_voices =
|
||||
self.metrics.active_voices.load(Ordering::Relaxed) as usize;
|
||||
self.app.metrics.peak_voices = self.app.metrics.peak_voices.max(self.app.metrics.active_voices);
|
||||
self.app.metrics.peak_voices = self
|
||||
.app
|
||||
.metrics
|
||||
.peak_voices
|
||||
.max(self.app.metrics.active_voices);
|
||||
self.app.metrics.cpu_load = self.metrics.load.get_load();
|
||||
self.app.metrics.schedule_depth =
|
||||
self.metrics.schedule_depth.load(Ordering::Relaxed) as usize;
|
||||
@@ -373,6 +395,22 @@ impl eframe::App for CagireDesktop {
|
||||
self.handle_audio_restart();
|
||||
self.update_metrics();
|
||||
|
||||
ctx.input(|i| {
|
||||
if let Some(pos) = i.pointer.latest_pos() {
|
||||
let screen = i.viewport_rect();
|
||||
let nx = (pos.x / screen.width()).clamp(0.0, 1.0);
|
||||
let ny = (pos.y / screen.height()).clamp(0.0, 1.0);
|
||||
self.mouse_x.store(nx.to_bits(), Ordering::Relaxed);
|
||||
self.mouse_y.store(ny.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
let down = if i.pointer.primary_down() {
|
||||
1.0_f32
|
||||
} else {
|
||||
0.0_f32
|
||||
};
|
||||
self.mouse_down.store(down.to_bits(), Ordering::Relaxed);
|
||||
});
|
||||
|
||||
let Some(ref sequencer) = self.sequencer else {
|
||||
return;
|
||||
};
|
||||
@@ -395,6 +433,59 @@ impl eframe::App for CagireDesktop {
|
||||
self.app.flush_queued_changes(&sequencer.cmd_tx);
|
||||
self.app.flush_dirty_patterns(&sequencer.cmd_tx);
|
||||
|
||||
while let Ok(midi_cmd) = self.midi_rx.try_recv() {
|
||||
match midi_cmd {
|
||||
MidiCommand::NoteOn {
|
||||
device,
|
||||
channel,
|
||||
note,
|
||||
velocity,
|
||||
} => {
|
||||
self.app.midi.send_note_on(device, channel, note, velocity);
|
||||
}
|
||||
MidiCommand::NoteOff {
|
||||
device,
|
||||
channel,
|
||||
note,
|
||||
} => {
|
||||
self.app.midi.send_note_off(device, channel, note);
|
||||
}
|
||||
MidiCommand::CC {
|
||||
device,
|
||||
channel,
|
||||
cc,
|
||||
value,
|
||||
} => {
|
||||
self.app.midi.send_cc(device, channel, cc, value);
|
||||
}
|
||||
MidiCommand::PitchBend {
|
||||
device,
|
||||
channel,
|
||||
value,
|
||||
} => {
|
||||
self.app.midi.send_pitch_bend(device, channel, value);
|
||||
}
|
||||
MidiCommand::Pressure {
|
||||
device,
|
||||
channel,
|
||||
value,
|
||||
} => {
|
||||
self.app.midi.send_pressure(device, channel, value);
|
||||
}
|
||||
MidiCommand::ProgramChange {
|
||||
device,
|
||||
channel,
|
||||
program,
|
||||
} => {
|
||||
self.app.midi.send_program_change(device, channel, program);
|
||||
}
|
||||
MidiCommand::Clock { device } => self.app.midi.send_realtime(device, 0xF8),
|
||||
MidiCommand::Start { device } => self.app.midi.send_realtime(device, 0xFA),
|
||||
MidiCommand::Stop { device } => self.app.midi.send_realtime(device, 0xFC),
|
||||
MidiCommand::Continue { device } => self.app.midi.send_realtime(device, 0xFB),
|
||||
}
|
||||
}
|
||||
|
||||
let should_quit = self.handle_input(ctx);
|
||||
if should_quit {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
|
||||
use crate::state::{FlashKind, Modal, PatternField};
|
||||
use crate::state::{ColorScheme, DeviceKind, Modal, PatternField, SettingKind};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub enum AppCommand {
|
||||
// Playback
|
||||
TogglePlaying,
|
||||
@@ -15,9 +14,6 @@ pub enum AppCommand {
|
||||
PrevStep,
|
||||
StepUp,
|
||||
StepDown,
|
||||
ToggleFocus,
|
||||
SelectEditBank(usize),
|
||||
SelectEditPattern(usize),
|
||||
|
||||
// Pattern editing
|
||||
ToggleSteps,
|
||||
@@ -39,7 +35,6 @@ pub enum AppCommand {
|
||||
// Script editing
|
||||
SaveEditorToStep,
|
||||
CompileCurrentStep,
|
||||
CompileAllSteps,
|
||||
DeleteStep {
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
@@ -80,10 +75,6 @@ pub enum AppCommand {
|
||||
DuplicateSteps,
|
||||
|
||||
// Pattern playback (staging)
|
||||
StagePatternToggle {
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
},
|
||||
CommitStagedChanges,
|
||||
ClearStagedChanges,
|
||||
|
||||
@@ -109,11 +100,6 @@ pub enum AppCommand {
|
||||
// UI
|
||||
SetStatus(String),
|
||||
ClearStatus,
|
||||
Flash {
|
||||
message: String,
|
||||
duration_ms: u64,
|
||||
kind: FlashKind,
|
||||
},
|
||||
OpenModal(Modal),
|
||||
CloseModal,
|
||||
OpenPatternModal(PatternField),
|
||||
@@ -169,4 +155,56 @@ pub enum AppCommand {
|
||||
PatternsEnter,
|
||||
PatternsBack,
|
||||
PatternsTogglePlay,
|
||||
|
||||
// UI state
|
||||
ClearMinimap,
|
||||
HideTitle,
|
||||
ToggleEditorStack,
|
||||
SetColorScheme(ColorScheme),
|
||||
ToggleRuntimeHighlight,
|
||||
ToggleCompletion,
|
||||
AdjustFlashBrightness(f32),
|
||||
|
||||
// Live keys
|
||||
ToggleLiveKeysFill,
|
||||
|
||||
// Panel
|
||||
ClosePanel,
|
||||
|
||||
// Selection
|
||||
SetSelectionAnchor(usize),
|
||||
|
||||
// Audio settings (engine page)
|
||||
AudioNextSection,
|
||||
AudioPrevSection,
|
||||
AudioOutputListUp,
|
||||
AudioOutputListDown(usize),
|
||||
AudioOutputPageUp,
|
||||
AudioOutputPageDown(usize),
|
||||
AudioInputListUp,
|
||||
AudioInputListDown(usize),
|
||||
AudioInputPageDown(usize),
|
||||
AudioSettingNext,
|
||||
AudioSettingPrev,
|
||||
SetOutputDevice(String),
|
||||
SetInputDevice(String),
|
||||
SetDeviceKind(DeviceKind),
|
||||
AdjustAudioSetting {
|
||||
setting: SettingKind,
|
||||
delta: i32,
|
||||
},
|
||||
AudioTriggerRestart,
|
||||
RemoveLastSamplePath,
|
||||
AudioRefreshDevices,
|
||||
|
||||
// Options page
|
||||
OptionsNextFocus,
|
||||
OptionsPrevFocus,
|
||||
ToggleRefreshRate,
|
||||
ToggleScope,
|
||||
ToggleSpectrum,
|
||||
|
||||
// Metrics
|
||||
ResetPeakVoices,
|
||||
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ pub struct ScopeBuffer {
|
||||
peak_right: AtomicU32,
|
||||
}
|
||||
|
||||
impl Default for ScopeBuffer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ScopeBuffer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -64,6 +70,12 @@ pub struct SpectrumBuffer {
|
||||
pub bands: [AtomicU32; 32],
|
||||
}
|
||||
|
||||
impl Default for SpectrumBuffer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SpectrumBuffer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -262,9 +274,9 @@ pub fn build_stream(
|
||||
let sr = sample_rate;
|
||||
let channels = config.channels as usize;
|
||||
let max_voices = config.max_voices;
|
||||
let metrics_clone = Arc::clone(&metrics);
|
||||
|
||||
let mut engine = Engine::new_with_metrics(sample_rate, channels, max_voices, Arc::clone(&metrics));
|
||||
let mut engine =
|
||||
Engine::new_with_metrics(sample_rate, channels, max_voices, Arc::clone(&metrics));
|
||||
engine.sample_index = initial_samples;
|
||||
|
||||
let (mut fft_producer, analysis_handle) = spawn_analysis_thread(sample_rate, spectrum_buffer);
|
||||
@@ -294,13 +306,6 @@ pub fn build_stream(
|
||||
AudioCommand::LoadSamples(samples) => {
|
||||
engine.sample_index.extend(samples);
|
||||
}
|
||||
AudioCommand::ResetEngine => {
|
||||
let old_samples = std::mem::take(&mut engine.sample_index);
|
||||
engine =
|
||||
Engine::new_with_metrics(sr, channels, max_voices, Arc::clone(&metrics_clone));
|
||||
engine.sample_index = old_samples;
|
||||
audio_sample_pos.store(0, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,12 @@ mod audio;
|
||||
mod link;
|
||||
pub mod sequencer;
|
||||
|
||||
// AnalysisHandle and SequencerHandle are used by src/bin/desktop.rs
|
||||
#[allow(unused_imports)]
|
||||
pub use audio::{build_stream, AnalysisHandle, AudioStreamConfig, ScopeBuffer, SpectrumBuffer};
|
||||
pub use link::LinkState;
|
||||
#[allow(unused_imports)]
|
||||
pub use sequencer::{
|
||||
spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand, SequencerConfig,
|
||||
SequencerHandle, SequencerSnapshot, StepSnapshot,
|
||||
spawn_sequencer, AudioCommand, MidiCommand, PatternChange, PatternSnapshot, SeqCommand,
|
||||
SequencerConfig, SequencerHandle, SequencerSnapshot, StepSnapshot,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use arc_swap::ArcSwap;
|
||||
use crossbeam_channel::{bounded, Receiver, Sender, TrySendError};
|
||||
use std::collections::HashMap;
|
||||
#[cfg(feature = "desktop")]
|
||||
use std::sync::atomic::AtomicU32;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64};
|
||||
use std::sync::Arc;
|
||||
use std::thread::{self, JoinHandle};
|
||||
@@ -8,7 +10,9 @@ use std::time::Duration;
|
||||
use thread_priority::{set_current_thread_priority, ThreadPriority};
|
||||
|
||||
use super::LinkState;
|
||||
use crate::model::{Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables};
|
||||
use crate::model::{
|
||||
CcAccess, Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables,
|
||||
};
|
||||
use crate::model::{LaunchQuantization, SyncMode, MAX_BANKS, MAX_PATTERNS};
|
||||
use crate::state::LiveKeyState;
|
||||
|
||||
@@ -40,15 +44,58 @@ impl PatternChange {
|
||||
}
|
||||
|
||||
pub enum AudioCommand {
|
||||
Evaluate {
|
||||
cmd: String,
|
||||
time: Option<f64>,
|
||||
},
|
||||
Evaluate { cmd: String, time: Option<f64> },
|
||||
Hush,
|
||||
Panic,
|
||||
LoadSamples(Vec<doux::sampling::SampleEntry>),
|
||||
#[allow(dead_code)]
|
||||
ResetEngine,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MidiCommand {
|
||||
NoteOn {
|
||||
device: u8,
|
||||
channel: u8,
|
||||
note: u8,
|
||||
velocity: u8,
|
||||
},
|
||||
NoteOff {
|
||||
device: u8,
|
||||
channel: u8,
|
||||
note: u8,
|
||||
},
|
||||
CC {
|
||||
device: u8,
|
||||
channel: u8,
|
||||
cc: u8,
|
||||
value: u8,
|
||||
},
|
||||
PitchBend {
|
||||
device: u8,
|
||||
channel: u8,
|
||||
value: u16,
|
||||
},
|
||||
Pressure {
|
||||
device: u8,
|
||||
channel: u8,
|
||||
value: u8,
|
||||
},
|
||||
ProgramChange {
|
||||
device: u8,
|
||||
channel: u8,
|
||||
program: u8,
|
||||
},
|
||||
Clock {
|
||||
device: u8,
|
||||
},
|
||||
Start {
|
||||
device: u8,
|
||||
},
|
||||
Stop {
|
||||
device: u8,
|
||||
},
|
||||
Continue {
|
||||
device: u8,
|
||||
},
|
||||
}
|
||||
|
||||
pub enum SeqCommand {
|
||||
@@ -142,6 +189,7 @@ impl SequencerSnapshot {
|
||||
pub struct SequencerHandle {
|
||||
pub cmd_tx: Sender<SeqCommand>,
|
||||
pub audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
|
||||
pub midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
|
||||
shared_state: Arc<ArcSwap<SharedSequencerState>>,
|
||||
thread: JoinHandle<()>,
|
||||
}
|
||||
@@ -163,6 +211,12 @@ impl SequencerHandle {
|
||||
new_rx
|
||||
}
|
||||
|
||||
pub fn swap_midi_channel(&self) -> Receiver<MidiCommand> {
|
||||
let (new_tx, new_rx) = bounded::<MidiCommand>(256);
|
||||
self.midi_tx.store(Arc::new(new_tx));
|
||||
new_rx
|
||||
}
|
||||
|
||||
pub fn shutdown(self) {
|
||||
let _ = self.cmd_tx.send(SeqCommand::Shutdown);
|
||||
if let Err(e) = self.thread.join() {
|
||||
@@ -191,6 +245,7 @@ struct AudioState {
|
||||
active_patterns: HashMap<PatternId, ActivePattern>,
|
||||
pending_starts: Vec<PendingPattern>,
|
||||
pending_stops: Vec<PendingPattern>,
|
||||
flush_midi_notes: bool,
|
||||
}
|
||||
|
||||
impl AudioState {
|
||||
@@ -200,6 +255,7 @@ impl AudioState {
|
||||
active_patterns: HashMap::new(),
|
||||
pending_starts: Vec::new(),
|
||||
pending_stops: Vec::new(),
|
||||
flush_midi_notes: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +264,13 @@ pub struct SequencerConfig {
|
||||
pub audio_sample_pos: Arc<AtomicU64>,
|
||||
pub sample_rate: Arc<std::sync::atomic::AtomicU32>,
|
||||
pub lookahead_ms: Arc<std::sync::atomic::AtomicU32>,
|
||||
pub cc_access: Option<Arc<dyn CcAccess>>,
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mouse_x: Arc<AtomicU32>,
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mouse_y: Arc<AtomicU32>,
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mouse_down: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -221,14 +284,28 @@ pub fn spawn_sequencer(
|
||||
live_keys: Arc<LiveKeyState>,
|
||||
nudge_us: Arc<AtomicI64>,
|
||||
config: SequencerConfig,
|
||||
) -> (SequencerHandle, Receiver<AudioCommand>) {
|
||||
) -> (
|
||||
SequencerHandle,
|
||||
Receiver<AudioCommand>,
|
||||
Receiver<MidiCommand>,
|
||||
) {
|
||||
let (cmd_tx, cmd_rx) = bounded::<SeqCommand>(64);
|
||||
let (audio_tx, audio_rx) = bounded::<AudioCommand>(256);
|
||||
let (midi_tx, midi_rx) = bounded::<MidiCommand>(256);
|
||||
let audio_tx = Arc::new(ArcSwap::from_pointee(audio_tx));
|
||||
let midi_tx = Arc::new(ArcSwap::from_pointee(midi_tx));
|
||||
|
||||
let shared_state = Arc::new(ArcSwap::from_pointee(SharedSequencerState::default()));
|
||||
let shared_state_clone = Arc::clone(&shared_state);
|
||||
let audio_tx_for_thread = Arc::clone(&audio_tx);
|
||||
let midi_tx_for_thread = Arc::clone(&midi_tx);
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
let mouse_x = config.mouse_x;
|
||||
#[cfg(feature = "desktop")]
|
||||
let mouse_y = config.mouse_y;
|
||||
#[cfg(feature = "desktop")]
|
||||
let mouse_down = config.mouse_down;
|
||||
|
||||
let thread = thread::Builder::new()
|
||||
.name("sequencer".into())
|
||||
@@ -236,6 +313,7 @@ pub fn spawn_sequencer(
|
||||
sequencer_loop(
|
||||
cmd_rx,
|
||||
audio_tx_for_thread,
|
||||
midi_tx_for_thread,
|
||||
link,
|
||||
playing,
|
||||
variables,
|
||||
@@ -248,6 +326,13 @@ pub fn spawn_sequencer(
|
||||
config.audio_sample_pos,
|
||||
config.sample_rate,
|
||||
config.lookahead_ms,
|
||||
config.cc_access,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_x,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_y,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_down,
|
||||
);
|
||||
})
|
||||
.expect("Failed to spawn sequencer thread");
|
||||
@@ -255,10 +340,11 @@ pub fn spawn_sequencer(
|
||||
let handle = SequencerHandle {
|
||||
cmd_tx,
|
||||
audio_tx,
|
||||
midi_tx,
|
||||
shared_state,
|
||||
thread,
|
||||
};
|
||||
(handle, audio_rx)
|
||||
(handle, audio_rx, midi_rx)
|
||||
}
|
||||
|
||||
struct PatternCache {
|
||||
@@ -364,6 +450,10 @@ impl RunsCounter {
|
||||
*count += 1;
|
||||
current
|
||||
}
|
||||
|
||||
fn clear_pattern(&mut self, bank: usize, pattern: usize) {
|
||||
self.counts.retain(|&(b, p, _), _| b != bank || p != pattern);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct TickInput {
|
||||
@@ -377,6 +467,12 @@ pub(crate) struct TickInput {
|
||||
pub current_time_us: i64,
|
||||
pub engine_time: f64,
|
||||
pub lookahead_secs: f64,
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mouse_x: f64,
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mouse_y: f64,
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mouse_down: f64,
|
||||
}
|
||||
|
||||
pub struct TimestampedCommand {
|
||||
@@ -388,6 +484,7 @@ pub(crate) struct TickOutput {
|
||||
pub audio_commands: Vec<TimestampedCommand>,
|
||||
pub new_tempo: Option<f64>,
|
||||
pub shared_state: SharedSequencerState,
|
||||
pub flush_midi_notes: bool,
|
||||
}
|
||||
|
||||
struct StepResult {
|
||||
@@ -434,6 +531,12 @@ impl KeyCache {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ActiveNote {
|
||||
off_time_us: i64,
|
||||
start_time_us: i64,
|
||||
}
|
||||
|
||||
pub(crate) struct SequencerState {
|
||||
audio_state: AudioState,
|
||||
pattern_cache: PatternCache,
|
||||
@@ -446,10 +549,17 @@ pub(crate) struct SequencerState {
|
||||
speed_overrides: HashMap<(usize, usize), f64>,
|
||||
key_cache: KeyCache,
|
||||
buf_audio_commands: Vec<TimestampedCommand>,
|
||||
cc_access: Option<Arc<dyn CcAccess>>,
|
||||
active_notes: HashMap<(u8, u8, u8), ActiveNote>,
|
||||
}
|
||||
|
||||
impl SequencerState {
|
||||
pub fn new(variables: Variables, dict: Dictionary, rng: Rng) -> Self {
|
||||
pub fn new(
|
||||
variables: Variables,
|
||||
dict: Dictionary,
|
||||
rng: Rng,
|
||||
cc_access: Option<Arc<dyn CcAccess>>,
|
||||
) -> Self {
|
||||
let script_engine = ScriptEngine::new(Arc::clone(&variables), dict, rng);
|
||||
Self {
|
||||
audio_state: AudioState::new(),
|
||||
@@ -463,6 +573,8 @@ impl SequencerState {
|
||||
speed_overrides: HashMap::new(),
|
||||
key_cache: KeyCache::new(),
|
||||
buf_audio_commands: Vec::new(),
|
||||
cc_access,
|
||||
active_notes: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +625,7 @@ impl SequencerState {
|
||||
self.audio_state.pending_stops.clear();
|
||||
Arc::make_mut(&mut self.step_traces).clear();
|
||||
self.runs_counter.counts.clear();
|
||||
self.audio_state.flush_midi_notes = true;
|
||||
}
|
||||
SeqCommand::Shutdown => {}
|
||||
}
|
||||
@@ -549,6 +662,12 @@ impl SequencerState {
|
||||
input.current_time_us,
|
||||
input.engine_time,
|
||||
input.lookahead_secs,
|
||||
#[cfg(feature = "desktop")]
|
||||
input.mouse_x,
|
||||
#[cfg(feature = "desktop")]
|
||||
input.mouse_y,
|
||||
#[cfg(feature = "desktop")]
|
||||
input.mouse_down,
|
||||
);
|
||||
|
||||
let vars = self.read_variables(&steps.completed_iterations, &stopped, steps.any_step_fired);
|
||||
@@ -556,10 +675,12 @@ impl SequencerState {
|
||||
|
||||
self.audio_state.prev_beat = beat;
|
||||
|
||||
let flush = std::mem::take(&mut self.audio_state.flush_midi_notes);
|
||||
TickOutput {
|
||||
audio_commands: std::mem::take(&mut self.buf_audio_commands),
|
||||
new_tempo: vars.new_tempo,
|
||||
shared_state: self.build_shared_state(),
|
||||
flush_midi_notes: flush,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,10 +694,12 @@ impl SequencerState {
|
||||
self.audio_state.pending_starts.clear();
|
||||
self.audio_state.prev_beat = -1.0;
|
||||
self.buf_audio_commands.clear();
|
||||
let flush = std::mem::take(&mut self.audio_state.flush_midi_notes);
|
||||
TickOutput {
|
||||
audio_commands: std::mem::take(&mut self.buf_audio_commands),
|
||||
new_tempo: None,
|
||||
shared_state: self.build_shared_state(),
|
||||
flush_midi_notes: flush,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,6 +720,7 @@ impl SequencerState {
|
||||
}
|
||||
}
|
||||
};
|
||||
self.runs_counter.clear_pattern(pending.id.bank, pending.id.pattern);
|
||||
self.audio_state.active_patterns.insert(
|
||||
pending.id,
|
||||
ActivePattern {
|
||||
@@ -638,6 +762,9 @@ impl SequencerState {
|
||||
_current_time_us: i64,
|
||||
engine_time: f64,
|
||||
lookahead_secs: f64,
|
||||
#[cfg(feature = "desktop")] mouse_x: f64,
|
||||
#[cfg(feature = "desktop")] mouse_y: f64,
|
||||
#[cfg(feature = "desktop")] mouse_down: f64,
|
||||
) -> StepResult {
|
||||
self.buf_audio_commands.clear();
|
||||
let mut result = StepResult {
|
||||
@@ -699,6 +826,13 @@ impl SequencerState {
|
||||
speed: speed_mult,
|
||||
fill,
|
||||
nudge_secs,
|
||||
cc_access: self.cc_access.clone(),
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_x,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_y,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_down,
|
||||
};
|
||||
if let Some(script) = resolved_script {
|
||||
let mut trace = ExecutionTrace::default();
|
||||
@@ -841,6 +975,7 @@ impl SequencerState {
|
||||
fn sequencer_loop(
|
||||
cmd_rx: Receiver<SeqCommand>,
|
||||
audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
|
||||
midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
|
||||
link: Arc<LinkState>,
|
||||
playing: Arc<std::sync::atomic::AtomicBool>,
|
||||
variables: Variables,
|
||||
@@ -853,12 +988,16 @@ fn sequencer_loop(
|
||||
audio_sample_pos: Arc<AtomicU64>,
|
||||
sample_rate: Arc<std::sync::atomic::AtomicU32>,
|
||||
lookahead_ms: Arc<std::sync::atomic::AtomicU32>,
|
||||
cc_access: Option<Arc<dyn CcAccess>>,
|
||||
#[cfg(feature = "desktop")] mouse_x: Arc<AtomicU32>,
|
||||
#[cfg(feature = "desktop")] mouse_y: Arc<AtomicU32>,
|
||||
#[cfg(feature = "desktop")] mouse_down: Arc<AtomicU32>,
|
||||
) {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let _ = set_current_thread_priority(ThreadPriority::Max);
|
||||
|
||||
let mut seq_state = SequencerState::new(variables, dict, rng);
|
||||
let mut seq_state = SequencerState::new(variables, dict, rng, cc_access);
|
||||
|
||||
loop {
|
||||
let mut commands = Vec::new();
|
||||
@@ -894,23 +1033,99 @@ fn sequencer_loop(
|
||||
current_time_us,
|
||||
engine_time,
|
||||
lookahead_secs,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_x: f32::from_bits(mouse_x.load(Ordering::Relaxed)) as f64,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_y: f32::from_bits(mouse_y.load(Ordering::Relaxed)) as f64,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_down: f32::from_bits(mouse_down.load(Ordering::Relaxed)) as f64,
|
||||
};
|
||||
|
||||
let output = seq_state.tick(input);
|
||||
|
||||
for tsc in output.audio_commands {
|
||||
let cmd = AudioCommand::Evaluate {
|
||||
cmd: tsc.cmd,
|
||||
time: tsc.time,
|
||||
};
|
||||
match audio_tx.load().try_send(cmd) {
|
||||
Ok(()) => {}
|
||||
Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
|
||||
seq_state.dropped_events += 1;
|
||||
if let Some((midi_cmd, dur)) = parse_midi_command(&tsc.cmd) {
|
||||
match midi_tx.load().try_send(midi_cmd.clone()) {
|
||||
Ok(()) => {
|
||||
if let (
|
||||
MidiCommand::NoteOn {
|
||||
device,
|
||||
channel,
|
||||
note,
|
||||
..
|
||||
},
|
||||
Some(dur_secs),
|
||||
) = (&midi_cmd, dur)
|
||||
{
|
||||
let dur_us = (dur_secs * 1_000_000.0) as i64;
|
||||
seq_state.active_notes.insert(
|
||||
(*device, *channel, *note),
|
||||
ActiveNote {
|
||||
off_time_us: current_time_us + dur_us,
|
||||
start_time_us: current_time_us,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
|
||||
seq_state.dropped_events += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let cmd = AudioCommand::Evaluate {
|
||||
cmd: tsc.cmd,
|
||||
time: tsc.time,
|
||||
};
|
||||
match audio_tx.load().try_send(cmd) {
|
||||
Ok(()) => {}
|
||||
Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
|
||||
seq_state.dropped_events += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_NOTE_DURATION_US: i64 = 30_000_000; // 30 second safety timeout
|
||||
|
||||
if output.flush_midi_notes {
|
||||
for ((device, channel, note), _) in seq_state.active_notes.drain() {
|
||||
let _ = midi_tx.load().try_send(MidiCommand::NoteOff {
|
||||
device,
|
||||
channel,
|
||||
note,
|
||||
});
|
||||
}
|
||||
// Send MIDI panic (CC 123 = All Notes Off) on all 16 channels for all devices
|
||||
for dev in 0..4u8 {
|
||||
for chan in 0..16u8 {
|
||||
let _ = midi_tx.load().try_send(MidiCommand::CC {
|
||||
device: dev,
|
||||
channel: chan,
|
||||
cc: 123,
|
||||
value: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
seq_state
|
||||
.active_notes
|
||||
.retain(|&(device, channel, note), active| {
|
||||
let should_release = current_time_us >= active.off_time_us;
|
||||
let timed_out = (current_time_us - active.start_time_us) > MAX_NOTE_DURATION_US;
|
||||
|
||||
if should_release || timed_out {
|
||||
let _ = midi_tx.load().try_send(MidiCommand::NoteOff {
|
||||
device,
|
||||
channel,
|
||||
note,
|
||||
});
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(t) = output.new_tempo {
|
||||
link.set_tempo(t);
|
||||
}
|
||||
@@ -921,6 +1136,103 @@ fn sequencer_loop(
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
|
||||
if !cmd.starts_with("/midi/") {
|
||||
return None;
|
||||
}
|
||||
let parts: Vec<&str> = cmd.split('/').filter(|s| !s.is_empty()).collect();
|
||||
if parts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let find_param = |key: &str| -> Option<&str> {
|
||||
parts
|
||||
.iter()
|
||||
.position(|&s| s == key)
|
||||
.and_then(|i| parts.get(i + 1).copied())
|
||||
};
|
||||
|
||||
let device: u8 = find_param("dev").and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
|
||||
match parts[1] {
|
||||
"note" => {
|
||||
// /midi/note/<note>/vel/<vel>/chan/<chan>/dur/<dur>/dev/<dev>
|
||||
let note: u8 = parts.get(2)?.parse().ok()?;
|
||||
let vel: u8 = find_param("vel")?.parse().ok()?;
|
||||
let chan: u8 = find_param("chan")?.parse().ok()?;
|
||||
let dur: Option<f64> = find_param("dur").and_then(|s| s.parse().ok());
|
||||
Some((
|
||||
MidiCommand::NoteOn {
|
||||
device,
|
||||
channel: chan,
|
||||
note,
|
||||
velocity: vel,
|
||||
},
|
||||
dur,
|
||||
))
|
||||
}
|
||||
"cc" => {
|
||||
// /midi/cc/<cc>/<val>/chan/<chan>/dev/<dev>
|
||||
let cc: u8 = parts.get(2)?.parse().ok()?;
|
||||
let val: u8 = parts.get(3)?.parse().ok()?;
|
||||
let chan: u8 = find_param("chan")?.parse().ok()?;
|
||||
Some((
|
||||
MidiCommand::CC {
|
||||
device,
|
||||
channel: chan,
|
||||
cc,
|
||||
value: val,
|
||||
},
|
||||
None,
|
||||
))
|
||||
}
|
||||
"bend" => {
|
||||
// /midi/bend/<value>/chan/<chan>/dev/<dev>
|
||||
let value: u16 = parts.get(2)?.parse().ok()?;
|
||||
let chan: u8 = find_param("chan")?.parse().ok()?;
|
||||
Some((
|
||||
MidiCommand::PitchBend {
|
||||
device,
|
||||
channel: chan,
|
||||
value,
|
||||
},
|
||||
None,
|
||||
))
|
||||
}
|
||||
"pressure" => {
|
||||
// /midi/pressure/<value>/chan/<chan>/dev/<dev>
|
||||
let value: u8 = parts.get(2)?.parse().ok()?;
|
||||
let chan: u8 = find_param("chan")?.parse().ok()?;
|
||||
Some((
|
||||
MidiCommand::Pressure {
|
||||
device,
|
||||
channel: chan,
|
||||
value,
|
||||
},
|
||||
None,
|
||||
))
|
||||
}
|
||||
"program" => {
|
||||
// /midi/program/<value>/chan/<chan>/dev/<dev>
|
||||
let program: u8 = parts.get(2)?.parse().ok()?;
|
||||
let chan: u8 = find_param("chan")?.parse().ok()?;
|
||||
Some((
|
||||
MidiCommand::ProgramChange {
|
||||
device,
|
||||
channel: chan,
|
||||
program,
|
||||
},
|
||||
None,
|
||||
))
|
||||
}
|
||||
"clock" => Some((MidiCommand::Clock { device }, None)),
|
||||
"start" => Some((MidiCommand::Start { device }, None)),
|
||||
"stop" => Some((MidiCommand::Stop { device }, None)),
|
||||
"continue" => Some((MidiCommand::Continue { device }, None)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -932,7 +1244,7 @@ mod tests {
|
||||
let rng: Rng = Arc::new(Mutex::new(
|
||||
<rand::rngs::StdRng as rand::SeedableRng>::seed_from_u64(0),
|
||||
));
|
||||
SequencerState::new(variables, dict, rng)
|
||||
SequencerState::new(variables, dict, rng, None)
|
||||
}
|
||||
|
||||
fn simple_pattern(length: usize) -> PatternSnapshot {
|
||||
@@ -967,6 +1279,12 @@ mod tests {
|
||||
current_time_us: 0,
|
||||
engine_time: 0.0,
|
||||
lookahead_secs: 0.0,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_x: 0.5,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_y: 0.5,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_down: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,6 +1300,12 @@ mod tests {
|
||||
current_time_us: 0,
|
||||
engine_time: 0.0,
|
||||
lookahead_secs: 0.0,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_x: 0.5,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_y: 0.5,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_down: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
268
src/input.rs
268
src/input.rs
@@ -52,11 +52,11 @@ pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down
|
||||
);
|
||||
if ctx.app.ui.minimap_until.is_some() && !(ctrl && is_arrow) {
|
||||
ctx.app.ui.minimap_until = None;
|
||||
ctx.dispatch(AppCommand::ClearMinimap);
|
||||
}
|
||||
|
||||
if ctx.app.ui.show_title {
|
||||
ctx.app.ui.show_title = false;
|
||||
ctx.dispatch(AppCommand::HideTitle);
|
||||
return InputResult::Continue;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ fn handle_live_keys(ctx: &mut InputContext, key: &KeyEvent) -> bool {
|
||||
match (key.code, key.kind) {
|
||||
_ if !matches!(ctx.app.ui.modal, Modal::None) => false,
|
||||
(KeyCode::Char('f'), KeyEventKind::Press) => {
|
||||
ctx.app.live_keys.flip_fill();
|
||||
ctx.dispatch(AppCommand::ToggleLiveKeysFill);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
@@ -258,6 +258,7 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
match mode {
|
||||
FileBrowserMode::Save => ctx.dispatch(AppCommand::Save(path)),
|
||||
FileBrowserMode::Load => {
|
||||
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
|
||||
ctx.dispatch(AppCommand::Load(path));
|
||||
load_project_samples(ctx);
|
||||
}
|
||||
@@ -506,13 +507,23 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
editor.search_prev();
|
||||
}
|
||||
KeyCode::Char('s') if ctrl => {
|
||||
ctx.app.editor_ctx.show_stack = !ctx.app.editor_ctx.show_stack;
|
||||
ctx.dispatch(AppCommand::ToggleEditorStack);
|
||||
}
|
||||
KeyCode::Char('r') if ctrl => {
|
||||
let script = ctx.app.editor_ctx.editor.lines().join("\n");
|
||||
match ctx.app.execute_script_oneshot(&script, ctx.link, ctx.audio_tx) {
|
||||
Ok(()) => ctx.app.ui.flash("Executed", 100, crate::state::FlashKind::Info),
|
||||
Err(e) => ctx.app.ui.flash(&format!("Error: {e}"), 200, crate::state::FlashKind::Error),
|
||||
match ctx
|
||||
.app
|
||||
.execute_script_oneshot(&script, ctx.link, ctx.audio_tx)
|
||||
{
|
||||
Ok(()) => ctx
|
||||
.app
|
||||
.ui
|
||||
.flash("Executed", 100, crate::state::FlashKind::Info),
|
||||
Err(e) => ctx.app.ui.flash(
|
||||
&format!("Error: {e}"),
|
||||
200,
|
||||
crate::state::FlashKind::Error,
|
||||
),
|
||||
}
|
||||
}
|
||||
KeyCode::Char('a') if ctrl => {
|
||||
@@ -745,8 +756,7 @@ fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
KeyCode::Left => state.collapse_at_cursor(),
|
||||
KeyCode::Char('/') => state.activate_search(),
|
||||
KeyCode::Esc | KeyCode::Tab => {
|
||||
ctx.app.panel.visible = false;
|
||||
ctx.app.panel.focus = PanelFocus::Main;
|
||||
ctx.dispatch(AppCommand::ClosePanel);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -783,25 +793,25 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
|
||||
}
|
||||
KeyCode::Left if shift && !ctrl => {
|
||||
if ctx.app.editor_ctx.selection_anchor.is_none() {
|
||||
ctx.app.editor_ctx.selection_anchor = Some(ctx.app.editor_ctx.step);
|
||||
ctx.dispatch(AppCommand::SetSelectionAnchor(ctx.app.editor_ctx.step));
|
||||
}
|
||||
ctx.dispatch(AppCommand::PrevStep);
|
||||
}
|
||||
KeyCode::Right if shift && !ctrl => {
|
||||
if ctx.app.editor_ctx.selection_anchor.is_none() {
|
||||
ctx.app.editor_ctx.selection_anchor = Some(ctx.app.editor_ctx.step);
|
||||
ctx.dispatch(AppCommand::SetSelectionAnchor(ctx.app.editor_ctx.step));
|
||||
}
|
||||
ctx.dispatch(AppCommand::NextStep);
|
||||
}
|
||||
KeyCode::Up if shift && !ctrl => {
|
||||
if ctx.app.editor_ctx.selection_anchor.is_none() {
|
||||
ctx.app.editor_ctx.selection_anchor = Some(ctx.app.editor_ctx.step);
|
||||
ctx.dispatch(AppCommand::SetSelectionAnchor(ctx.app.editor_ctx.step));
|
||||
}
|
||||
ctx.dispatch(AppCommand::StepUp);
|
||||
}
|
||||
KeyCode::Down if shift && !ctrl => {
|
||||
if ctx.app.editor_ctx.selection_anchor.is_none() {
|
||||
ctx.app.editor_ctx.selection_anchor = Some(ctx.app.editor_ctx.step);
|
||||
ctx.dispatch(AppCommand::SetSelectionAnchor(ctx.app.editor_ctx.step));
|
||||
}
|
||||
ctx.dispatch(AppCommand::StepDown);
|
||||
}
|
||||
@@ -910,9 +920,19 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
|
||||
let pattern = ctx.app.current_edit_pattern();
|
||||
if let Some(script) = pattern.resolve_script(ctx.app.editor_ctx.step) {
|
||||
if !script.trim().is_empty() {
|
||||
match ctx.app.execute_script_oneshot(script, ctx.link, ctx.audio_tx) {
|
||||
Ok(()) => ctx.app.ui.flash("Executed", 100, crate::state::FlashKind::Info),
|
||||
Err(e) => ctx.app.ui.flash(&format!("Error: {e}"), 200, crate::state::FlashKind::Error),
|
||||
match ctx
|
||||
.app
|
||||
.execute_script_oneshot(script, ctx.link, ctx.audio_tx)
|
||||
{
|
||||
Ok(()) => ctx
|
||||
.app
|
||||
.ui
|
||||
.flash("Executed", 100, crate::state::FlashKind::Info),
|
||||
Err(e) => ctx.app.ui.flash(
|
||||
&format!("Error: {e}"),
|
||||
200,
|
||||
crate::state::FlashKind::Error,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -923,7 +943,9 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
|
||||
ctx.app.editor_ctx.pattern,
|
||||
ctx.app.editor_ctx.step,
|
||||
);
|
||||
let current_name = ctx.app.current_edit_pattern()
|
||||
let current_name = ctx
|
||||
.app
|
||||
.current_edit_pattern()
|
||||
.step(step)
|
||||
.and_then(|s| s.name.clone())
|
||||
.unwrap_or_default();
|
||||
@@ -1063,15 +1085,15 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
selected: false,
|
||||
}));
|
||||
}
|
||||
KeyCode::Tab => ctx.app.audio.next_section(),
|
||||
KeyCode::BackTab => ctx.app.audio.prev_section(),
|
||||
KeyCode::Tab => ctx.dispatch(AppCommand::AudioNextSection),
|
||||
KeyCode::BackTab => ctx.dispatch(AppCommand::AudioPrevSection),
|
||||
KeyCode::Up => match ctx.app.audio.section {
|
||||
EngineSection::Devices => match ctx.app.audio.device_kind {
|
||||
DeviceKind::Output => ctx.app.audio.output_list.move_up(),
|
||||
DeviceKind::Input => ctx.app.audio.input_list.move_up(),
|
||||
DeviceKind::Output => ctx.dispatch(AppCommand::AudioOutputListUp),
|
||||
DeviceKind::Input => ctx.dispatch(AppCommand::AudioInputListUp),
|
||||
},
|
||||
EngineSection::Settings => {
|
||||
ctx.app.audio.setting_kind = ctx.app.audio.setting_kind.prev();
|
||||
ctx.dispatch(AppCommand::AudioSettingPrev);
|
||||
}
|
||||
EngineSection::Samples => {}
|
||||
},
|
||||
@@ -1079,22 +1101,22 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
EngineSection::Devices => match ctx.app.audio.device_kind {
|
||||
DeviceKind::Output => {
|
||||
let count = ctx.app.audio.output_devices.len();
|
||||
ctx.app.audio.output_list.move_down(count);
|
||||
ctx.dispatch(AppCommand::AudioOutputListDown(count));
|
||||
}
|
||||
DeviceKind::Input => {
|
||||
let count = ctx.app.audio.input_devices.len();
|
||||
ctx.app.audio.input_list.move_down(count);
|
||||
ctx.dispatch(AppCommand::AudioInputListDown(count));
|
||||
}
|
||||
},
|
||||
EngineSection::Settings => {
|
||||
ctx.app.audio.setting_kind = ctx.app.audio.setting_kind.next();
|
||||
ctx.dispatch(AppCommand::AudioSettingNext);
|
||||
}
|
||||
EngineSection::Samples => {}
|
||||
},
|
||||
KeyCode::PageUp => {
|
||||
if ctx.app.audio.section == EngineSection::Devices {
|
||||
match ctx.app.audio.device_kind {
|
||||
DeviceKind::Output => ctx.app.audio.output_list.page_up(),
|
||||
DeviceKind::Output => ctx.dispatch(AppCommand::AudioOutputPageUp),
|
||||
DeviceKind::Input => ctx.app.audio.input_list.page_up(),
|
||||
}
|
||||
}
|
||||
@@ -1104,11 +1126,11 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
match ctx.app.audio.device_kind {
|
||||
DeviceKind::Output => {
|
||||
let count = ctx.app.audio.output_devices.len();
|
||||
ctx.app.audio.output_list.page_down(count);
|
||||
ctx.dispatch(AppCommand::AudioOutputPageDown(count));
|
||||
}
|
||||
DeviceKind::Input => {
|
||||
let count = ctx.app.audio.input_devices.len();
|
||||
ctx.app.audio.input_list.page_down(count);
|
||||
ctx.dispatch(AppCommand::AudioInputPageDown(count));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1119,16 +1141,16 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
DeviceKind::Output => {
|
||||
let cursor = ctx.app.audio.output_list.cursor;
|
||||
if cursor < ctx.app.audio.output_devices.len() {
|
||||
ctx.app.audio.config.output_device =
|
||||
Some(ctx.app.audio.output_devices[cursor].name.clone());
|
||||
let name = ctx.app.audio.output_devices[cursor].name.clone();
|
||||
ctx.dispatch(AppCommand::SetOutputDevice(name));
|
||||
ctx.app.save_settings(ctx.link);
|
||||
}
|
||||
}
|
||||
DeviceKind::Input => {
|
||||
let cursor = ctx.app.audio.input_list.cursor;
|
||||
if cursor < ctx.app.audio.input_devices.len() {
|
||||
ctx.app.audio.config.input_device =
|
||||
Some(ctx.app.audio.input_devices[cursor].name.clone());
|
||||
let name = ctx.app.audio.input_devices[cursor].name.clone();
|
||||
ctx.dispatch(AppCommand::SetInputDevice(name));
|
||||
ctx.app.save_settings(ctx.link);
|
||||
}
|
||||
}
|
||||
@@ -1137,20 +1159,32 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
KeyCode::Left => match ctx.app.audio.section {
|
||||
EngineSection::Devices => {
|
||||
ctx.app.audio.device_kind = DeviceKind::Output;
|
||||
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Output));
|
||||
}
|
||||
EngineSection::Settings => {
|
||||
match ctx.app.audio.setting_kind {
|
||||
SettingKind::Channels => ctx.app.audio.adjust_channels(-1),
|
||||
SettingKind::BufferSize => ctx.app.audio.adjust_buffer_size(-64),
|
||||
SettingKind::Polyphony => ctx.app.audio.adjust_max_voices(-1),
|
||||
SettingKind::Channels => ctx.dispatch(AppCommand::AdjustAudioSetting {
|
||||
setting: SettingKind::Channels,
|
||||
delta: -1,
|
||||
}),
|
||||
SettingKind::BufferSize => ctx.dispatch(AppCommand::AdjustAudioSetting {
|
||||
setting: SettingKind::BufferSize,
|
||||
delta: -64,
|
||||
}),
|
||||
SettingKind::Polyphony => ctx.dispatch(AppCommand::AdjustAudioSetting {
|
||||
setting: SettingKind::Polyphony,
|
||||
delta: -1,
|
||||
}),
|
||||
SettingKind::Nudge => {
|
||||
let prev = ctx.nudge_us.load(Ordering::Relaxed);
|
||||
ctx.nudge_us
|
||||
.store((prev - 1000).max(-100_000), Ordering::Relaxed);
|
||||
}
|
||||
SettingKind::Lookahead => {
|
||||
ctx.app.audio.adjust_lookahead(-1);
|
||||
ctx.dispatch(AppCommand::AdjustAudioSetting {
|
||||
setting: SettingKind::Lookahead,
|
||||
delta: -1,
|
||||
});
|
||||
ctx.lookahead_ms
|
||||
.store(ctx.app.audio.config.lookahead_ms, Ordering::Relaxed);
|
||||
}
|
||||
@@ -1161,20 +1195,32 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
},
|
||||
KeyCode::Right => match ctx.app.audio.section {
|
||||
EngineSection::Devices => {
|
||||
ctx.app.audio.device_kind = DeviceKind::Input;
|
||||
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Input));
|
||||
}
|
||||
EngineSection::Settings => {
|
||||
match ctx.app.audio.setting_kind {
|
||||
SettingKind::Channels => ctx.app.audio.adjust_channels(1),
|
||||
SettingKind::BufferSize => ctx.app.audio.adjust_buffer_size(64),
|
||||
SettingKind::Polyphony => ctx.app.audio.adjust_max_voices(1),
|
||||
SettingKind::Channels => ctx.dispatch(AppCommand::AdjustAudioSetting {
|
||||
setting: SettingKind::Channels,
|
||||
delta: 1,
|
||||
}),
|
||||
SettingKind::BufferSize => ctx.dispatch(AppCommand::AdjustAudioSetting {
|
||||
setting: SettingKind::BufferSize,
|
||||
delta: 64,
|
||||
}),
|
||||
SettingKind::Polyphony => ctx.dispatch(AppCommand::AdjustAudioSetting {
|
||||
setting: SettingKind::Polyphony,
|
||||
delta: 1,
|
||||
}),
|
||||
SettingKind::Nudge => {
|
||||
let prev = ctx.nudge_us.load(Ordering::Relaxed);
|
||||
ctx.nudge_us
|
||||
.store((prev + 1000).min(100_000), Ordering::Relaxed);
|
||||
}
|
||||
SettingKind::Lookahead => {
|
||||
ctx.app.audio.adjust_lookahead(1);
|
||||
ctx.dispatch(AppCommand::AdjustAudioSetting {
|
||||
setting: SettingKind::Lookahead,
|
||||
delta: 1,
|
||||
});
|
||||
ctx.lookahead_ms
|
||||
.store(ctx.app.audio.config.lookahead_ms, Ordering::Relaxed);
|
||||
}
|
||||
@@ -1183,7 +1229,7 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
EngineSection::Samples => {}
|
||||
},
|
||||
KeyCode::Char('R') => ctx.app.audio.trigger_restart(),
|
||||
KeyCode::Char('R') => ctx.dispatch(AppCommand::AudioTriggerRestart),
|
||||
KeyCode::Char('A') => {
|
||||
use crate::state::file_browser::FileBrowserState;
|
||||
let state = FileBrowserState::new_load(String::new());
|
||||
@@ -1191,9 +1237,9 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
KeyCode::Char('D') => {
|
||||
if ctx.app.audio.section == EngineSection::Samples {
|
||||
ctx.app.audio.remove_last_sample_path();
|
||||
ctx.dispatch(AppCommand::RemoveLastSamplePath);
|
||||
} else {
|
||||
ctx.app.audio.refresh_devices();
|
||||
ctx.dispatch(AppCommand::AudioRefreshDevices);
|
||||
let out_count = ctx.app.audio.output_devices.len();
|
||||
let in_count = ctx.app.audio.input_devices.len();
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
@@ -1209,7 +1255,7 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
let _ = ctx.audio_tx.load().send(AudioCommand::Panic);
|
||||
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
|
||||
}
|
||||
KeyCode::Char('r') => ctx.app.metrics.peak_voices = 0,
|
||||
KeyCode::Char('r') => ctx.dispatch(AppCommand::ResetPeakVoices),
|
||||
KeyCode::Char('t') => {
|
||||
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate {
|
||||
cmd: "/sound/sine/dur/0.5/decay/0.2".into(),
|
||||
@@ -1231,8 +1277,8 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
selected: false,
|
||||
}));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Tab => ctx.app.options.next_focus(),
|
||||
KeyCode::Up | KeyCode::BackTab => ctx.app.options.prev_focus(),
|
||||
KeyCode::Down | KeyCode::Tab => ctx.dispatch(AppCommand::OptionsNextFocus),
|
||||
KeyCode::Up | KeyCode::BackTab => ctx.dispatch(AppCommand::OptionsPrevFocus),
|
||||
KeyCode::Left | KeyCode::Right => {
|
||||
match ctx.app.options.focus {
|
||||
OptionsFocus::ColorScheme => {
|
||||
@@ -1241,26 +1287,24 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
} else {
|
||||
ctx.app.ui.color_scheme.next()
|
||||
};
|
||||
ctx.app.ui.color_scheme = new_scheme;
|
||||
crate::theme::set(new_scheme.to_theme());
|
||||
ctx.dispatch(AppCommand::SetColorScheme(new_scheme));
|
||||
}
|
||||
OptionsFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(),
|
||||
OptionsFocus::RefreshRate => ctx.dispatch(AppCommand::ToggleRefreshRate),
|
||||
OptionsFocus::RuntimeHighlight => {
|
||||
ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight
|
||||
ctx.dispatch(AppCommand::ToggleRuntimeHighlight);
|
||||
}
|
||||
OptionsFocus::ShowScope => {
|
||||
ctx.app.audio.config.show_scope = !ctx.app.audio.config.show_scope
|
||||
ctx.dispatch(AppCommand::ToggleScope);
|
||||
}
|
||||
OptionsFocus::ShowSpectrum => {
|
||||
ctx.app.audio.config.show_spectrum = !ctx.app.audio.config.show_spectrum
|
||||
ctx.dispatch(AppCommand::ToggleSpectrum);
|
||||
}
|
||||
OptionsFocus::ShowCompletion => {
|
||||
ctx.app.ui.show_completion = !ctx.app.ui.show_completion
|
||||
ctx.dispatch(AppCommand::ToggleCompletion);
|
||||
}
|
||||
OptionsFocus::FlashBrightness => {
|
||||
let delta = if key.code == KeyCode::Left { -0.1 } else { 0.1 };
|
||||
ctx.app.ui.flash_brightness =
|
||||
(ctx.app.ui.flash_brightness + delta).clamp(0.0, 1.0);
|
||||
ctx.dispatch(AppCommand::AdjustFlashBrightness(delta));
|
||||
}
|
||||
OptionsFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
|
||||
OptionsFocus::StartStopSync => ctx
|
||||
@@ -1270,6 +1314,116 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
let delta = if key.code == KeyCode::Left { -1.0 } else { 1.0 };
|
||||
ctx.link.set_quantum(ctx.link.quantum() + delta);
|
||||
}
|
||||
OptionsFocus::MidiOutput0
|
||||
| OptionsFocus::MidiOutput1
|
||||
| OptionsFocus::MidiOutput2
|
||||
| OptionsFocus::MidiOutput3 => {
|
||||
let slot = match ctx.app.options.focus {
|
||||
OptionsFocus::MidiOutput0 => 0,
|
||||
OptionsFocus::MidiOutput1 => 1,
|
||||
OptionsFocus::MidiOutput2 => 2,
|
||||
OptionsFocus::MidiOutput3 => 3,
|
||||
_ => 0,
|
||||
};
|
||||
let all_devices = crate::midi::list_midi_outputs();
|
||||
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| {
|
||||
ctx.app.midi.selected_outputs[slot] == Some(*idx)
|
||||
|| !ctx
|
||||
.app
|
||||
.midi
|
||||
.selected_outputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(s, sel)| s != slot && *sel == Some(*idx))
|
||||
})
|
||||
.collect();
|
||||
let total_options = available.len() + 1;
|
||||
let current_pos = ctx.app.midi.selected_outputs[slot]
|
||||
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
|
||||
.map(|p| p + 1)
|
||||
.unwrap_or(0);
|
||||
let new_pos = if key.code == KeyCode::Left {
|
||||
if current_pos == 0 {
|
||||
total_options - 1
|
||||
} else {
|
||||
current_pos - 1
|
||||
}
|
||||
} else {
|
||||
(current_pos + 1) % total_options
|
||||
};
|
||||
if new_pos == 0 {
|
||||
ctx.app.midi.disconnect_output(slot);
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"MIDI output {slot}: disconnected"
|
||||
)));
|
||||
} else {
|
||||
let (device_idx, device) = available[new_pos - 1];
|
||||
if ctx.app.midi.connect_output(slot, device_idx).is_ok() {
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"MIDI output {}: {}",
|
||||
slot, device.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
OptionsFocus::MidiInput0
|
||||
| OptionsFocus::MidiInput1
|
||||
| OptionsFocus::MidiInput2
|
||||
| OptionsFocus::MidiInput3 => {
|
||||
let slot = match ctx.app.options.focus {
|
||||
OptionsFocus::MidiInput0 => 0,
|
||||
OptionsFocus::MidiInput1 => 1,
|
||||
OptionsFocus::MidiInput2 => 2,
|
||||
OptionsFocus::MidiInput3 => 3,
|
||||
_ => 0,
|
||||
};
|
||||
let all_devices = crate::midi::list_midi_inputs();
|
||||
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| {
|
||||
ctx.app.midi.selected_inputs[slot] == Some(*idx)
|
||||
|| !ctx
|
||||
.app
|
||||
.midi
|
||||
.selected_inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(s, sel)| s != slot && *sel == Some(*idx))
|
||||
})
|
||||
.collect();
|
||||
let total_options = available.len() + 1;
|
||||
let current_pos = ctx.app.midi.selected_inputs[slot]
|
||||
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
|
||||
.map(|p| p + 1)
|
||||
.unwrap_or(0);
|
||||
let new_pos = if key.code == KeyCode::Left {
|
||||
if current_pos == 0 {
|
||||
total_options - 1
|
||||
} else {
|
||||
current_pos - 1
|
||||
}
|
||||
} else {
|
||||
(current_pos + 1) % total_options
|
||||
};
|
||||
if new_pos == 0 {
|
||||
ctx.app.midi.disconnect_input(slot);
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"MIDI input {slot}: disconnected"
|
||||
)));
|
||||
} else {
|
||||
let (device_idx, device) = available[new_pos - 1];
|
||||
if ctx.app.midi.connect_input(slot, device_idx).is_ok() {
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"MIDI input {}: {}",
|
||||
slot, device.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.app.save_settings(ctx.link);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod app;
|
||||
pub mod commands;
|
||||
pub mod engine;
|
||||
pub mod input;
|
||||
pub mod midi;
|
||||
pub mod model;
|
||||
pub mod page;
|
||||
pub mod services;
|
||||
|
||||
92
src/main.rs
92
src/main.rs
@@ -2,6 +2,7 @@ mod app;
|
||||
mod commands;
|
||||
mod engine;
|
||||
mod input;
|
||||
mod midi;
|
||||
mod model;
|
||||
mod page;
|
||||
mod services;
|
||||
@@ -37,7 +38,7 @@ use settings::Settings;
|
||||
use state::audio::RefreshRate;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "cagire", about = "A step sequencer with Ableton Link support")]
|
||||
#[command(name = "cagire", version, about = "Forth-based live coding sequencer")]
|
||||
struct Args {
|
||||
/// Directory containing audio samples to load (can be specified multiple times)
|
||||
#[arg(short, long)]
|
||||
@@ -101,6 +102,24 @@ fn main() -> io::Result<()> {
|
||||
app.ui.color_scheme = settings.display.color_scheme;
|
||||
theme::set(settings.display.color_scheme.to_theme());
|
||||
|
||||
// Load MIDI settings
|
||||
let outputs = midi::list_midi_outputs();
|
||||
let inputs = midi::list_midi_inputs();
|
||||
for (slot, name) in settings.midi.output_devices.iter().enumerate() {
|
||||
if !name.is_empty() {
|
||||
if let Some(idx) = outputs.iter().position(|d| &d.name == name) {
|
||||
let _ = app.midi.connect_output(slot, idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (slot, name) in settings.midi.input_devices.iter().enumerate() {
|
||||
if !name.is_empty() {
|
||||
if let Some(idx) = inputs.iter().position(|d| &d.name == name) {
|
||||
let _ = app.midi.connect_input(slot, idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let metrics = Arc::new(EngineMetrics::default());
|
||||
let scope_buffer = Arc::new(ScopeBuffer::new());
|
||||
let spectrum_buffer = Arc::new(SpectrumBuffer::new());
|
||||
@@ -116,13 +135,27 @@ fn main() -> io::Result<()> {
|
||||
initial_samples.extend(index);
|
||||
}
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
let mouse_x = Arc::new(AtomicU32::new(0.5_f32.to_bits()));
|
||||
#[cfg(feature = "desktop")]
|
||||
let mouse_y = Arc::new(AtomicU32::new(0.5_f32.to_bits()));
|
||||
#[cfg(feature = "desktop")]
|
||||
let mouse_down = Arc::new(AtomicU32::new(0.0_f32.to_bits()));
|
||||
|
||||
let seq_config = SequencerConfig {
|
||||
audio_sample_pos: Arc::clone(&audio_sample_pos),
|
||||
sample_rate: Arc::clone(&sample_rate_shared),
|
||||
lookahead_ms: Arc::clone(&lookahead_ms),
|
||||
cc_access: Some(Arc::new(app.midi.cc_memory.clone()) as Arc<dyn crate::model::CcAccess>),
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_x: Arc::clone(&mouse_x),
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_y: Arc::clone(&mouse_y),
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_down: Arc::clone(&mouse_down),
|
||||
};
|
||||
|
||||
let (sequencer, initial_audio_rx) = spawn_sequencer(
|
||||
let (sequencer, initial_audio_rx, mut midi_rx) = spawn_sequencer(
|
||||
Arc::clone(&link),
|
||||
Arc::clone(&playing),
|
||||
Arc::clone(&app.variables),
|
||||
@@ -177,6 +210,7 @@ fn main() -> io::Result<()> {
|
||||
_analysis_handle = None;
|
||||
|
||||
let new_audio_rx = sequencer.swap_audio_channel();
|
||||
midi_rx = sequencer.swap_midi_channel();
|
||||
|
||||
let new_config = AudioStreamConfig {
|
||||
output_device: app.audio.config.output_device.clone(),
|
||||
@@ -220,6 +254,60 @@ fn main() -> io::Result<()> {
|
||||
|
||||
app.playback.playing = playing.load(Ordering::Relaxed);
|
||||
|
||||
// Process pending MIDI commands
|
||||
while let Ok(midi_cmd) = midi_rx.try_recv() {
|
||||
match midi_cmd {
|
||||
engine::MidiCommand::NoteOn {
|
||||
device,
|
||||
channel,
|
||||
note,
|
||||
velocity,
|
||||
} => {
|
||||
app.midi.send_note_on(device, channel, note, velocity);
|
||||
}
|
||||
engine::MidiCommand::NoteOff {
|
||||
device,
|
||||
channel,
|
||||
note,
|
||||
} => {
|
||||
app.midi.send_note_off(device, channel, note);
|
||||
}
|
||||
engine::MidiCommand::CC {
|
||||
device,
|
||||
channel,
|
||||
cc,
|
||||
value,
|
||||
} => {
|
||||
app.midi.send_cc(device, channel, cc, value);
|
||||
}
|
||||
engine::MidiCommand::PitchBend {
|
||||
device,
|
||||
channel,
|
||||
value,
|
||||
} => {
|
||||
app.midi.send_pitch_bend(device, channel, value);
|
||||
}
|
||||
engine::MidiCommand::Pressure {
|
||||
device,
|
||||
channel,
|
||||
value,
|
||||
} => {
|
||||
app.midi.send_pressure(device, channel, value);
|
||||
}
|
||||
engine::MidiCommand::ProgramChange {
|
||||
device,
|
||||
channel,
|
||||
program,
|
||||
} => {
|
||||
app.midi.send_program_change(device, channel, program);
|
||||
}
|
||||
engine::MidiCommand::Clock { device } => app.midi.send_realtime(device, 0xF8),
|
||||
engine::MidiCommand::Start { device } => app.midi.send_realtime(device, 0xFA),
|
||||
engine::MidiCommand::Stop { device } => app.midi.send_realtime(device, 0xFC),
|
||||
engine::MidiCommand::Continue { device } => app.midi.send_realtime(device, 0xFB),
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
app.metrics.active_voices = metrics.active_voices.load(Ordering::Relaxed) as usize;
|
||||
app.metrics.peak_voices = app.metrics.peak_voices.max(app.metrics.active_voices);
|
||||
|
||||
221
src/midi.rs
Normal file
221
src/midi.rs
Normal file
@@ -0,0 +1,221 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use midir::{MidiInput, MidiOutput};
|
||||
|
||||
use cagire_forth::CcAccess;
|
||||
|
||||
pub const MAX_MIDI_OUTPUTS: usize = 4;
|
||||
pub const MAX_MIDI_INPUTS: usize = 4;
|
||||
pub const MAX_MIDI_DEVICES: usize = 4;
|
||||
|
||||
/// Raw CC memory storage type
|
||||
type CcMemoryInner = Arc<Mutex<[[[u8; 128]; 16]; MAX_MIDI_DEVICES]>>;
|
||||
|
||||
/// CC memory storage: [device][channel][cc_number] -> value
|
||||
/// Wrapped in a newtype to implement CcAccess (orphan rule)
|
||||
#[derive(Clone)]
|
||||
pub struct CcMemory(CcMemoryInner);
|
||||
|
||||
impl CcMemory {
|
||||
pub fn new() -> Self {
|
||||
Self(Arc::new(Mutex::new([[[0u8; 128]; 16]; MAX_MIDI_DEVICES])))
|
||||
}
|
||||
|
||||
fn inner(&self) -> &CcMemoryInner {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Set a CC value (for testing)
|
||||
#[allow(dead_code)]
|
||||
pub fn set_cc(&self, device: usize, channel: usize, cc: usize, value: u8) {
|
||||
if let Ok(mut mem) = self.0.lock() {
|
||||
mem[device.min(MAX_MIDI_DEVICES - 1)][channel.min(15)][cc.min(127)] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CcMemory {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CcAccess for CcMemory {
|
||||
fn get_cc(&self, device: usize, channel: usize, cc: usize) -> u8 {
|
||||
self.0
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|mem| mem[device.min(MAX_MIDI_DEVICES - 1)][channel.min(15)][cc.min(127)])
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MidiDeviceInfo {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub fn list_midi_outputs() -> Vec<MidiDeviceInfo> {
|
||||
let Ok(midi_out) = MidiOutput::new("cagire-probe") else {
|
||||
return Vec::new();
|
||||
};
|
||||
midi_out
|
||||
.ports()
|
||||
.iter()
|
||||
.filter_map(|port| {
|
||||
midi_out
|
||||
.port_name(port)
|
||||
.ok()
|
||||
.map(|name| MidiDeviceInfo { name })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn list_midi_inputs() -> Vec<MidiDeviceInfo> {
|
||||
let Ok(midi_in) = MidiInput::new("cagire-probe") else {
|
||||
return Vec::new();
|
||||
};
|
||||
midi_in
|
||||
.ports()
|
||||
.iter()
|
||||
.filter_map(|port| {
|
||||
midi_in
|
||||
.port_name(port)
|
||||
.ok()
|
||||
.map(|name| MidiDeviceInfo { name })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub struct MidiState {
|
||||
output_conns: [Option<midir::MidiOutputConnection>; MAX_MIDI_OUTPUTS],
|
||||
input_conns: [Option<midir::MidiInputConnection<(CcMemoryInner, usize)>>; MAX_MIDI_INPUTS],
|
||||
pub selected_outputs: [Option<usize>; MAX_MIDI_OUTPUTS],
|
||||
pub selected_inputs: [Option<usize>; MAX_MIDI_INPUTS],
|
||||
pub cc_memory: CcMemory,
|
||||
}
|
||||
|
||||
impl Default for MidiState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MidiState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
output_conns: [None, None, None, None],
|
||||
input_conns: [None, None, None, None],
|
||||
selected_outputs: [None; MAX_MIDI_OUTPUTS],
|
||||
selected_inputs: [None; MAX_MIDI_INPUTS],
|
||||
cc_memory: CcMemory::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connect_output(&mut self, slot: usize, port_index: usize) -> Result<(), String> {
|
||||
if slot >= MAX_MIDI_OUTPUTS {
|
||||
return Err("Invalid output slot".to_string());
|
||||
}
|
||||
let midi_out = MidiOutput::new(&format!("cagire-out-{slot}")).map_err(|e| e.to_string())?;
|
||||
let ports = midi_out.ports();
|
||||
let port = ports.get(port_index).ok_or("MIDI output port not found")?;
|
||||
let conn = midi_out
|
||||
.connect(port, &format!("cagire-midi-out-{slot}"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.output_conns[slot] = Some(conn);
|
||||
self.selected_outputs[slot] = Some(port_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn disconnect_output(&mut self, slot: usize) {
|
||||
if slot < MAX_MIDI_OUTPUTS {
|
||||
self.output_conns[slot] = None;
|
||||
self.selected_outputs[slot] = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connect_input(&mut self, slot: usize, port_index: usize) -> Result<(), String> {
|
||||
if slot >= MAX_MIDI_INPUTS {
|
||||
return Err("Invalid input slot".to_string());
|
||||
}
|
||||
let midi_in = MidiInput::new(&format!("cagire-in-{slot}")).map_err(|e| e.to_string())?;
|
||||
let ports = midi_in.ports();
|
||||
let port = ports.get(port_index).ok_or("MIDI input port not found")?;
|
||||
|
||||
let cc_mem = Arc::clone(self.cc_memory.inner());
|
||||
let conn = midi_in
|
||||
.connect(
|
||||
port,
|
||||
&format!("cagire-midi-in-{slot}"),
|
||||
move |_timestamp, message, (cc_mem, slot)| {
|
||||
if message.len() >= 3 {
|
||||
let status = message[0];
|
||||
let data1 = message[1] as usize;
|
||||
let data2 = message[2];
|
||||
if (status & 0xF0) == 0xB0 && data1 < 128 {
|
||||
let channel = (status & 0x0F) as usize;
|
||||
if let Ok(mut mem) = cc_mem.lock() {
|
||||
mem[*slot][channel][data1] = data2;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
(cc_mem, slot),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
self.input_conns[slot] = Some(conn);
|
||||
self.selected_inputs[slot] = Some(port_index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn disconnect_input(&mut self, slot: usize) {
|
||||
if slot < MAX_MIDI_INPUTS {
|
||||
self.input_conns[slot] = None;
|
||||
self.selected_inputs[slot] = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn send_message(&mut self, device: u8, message: &[u8]) {
|
||||
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
|
||||
if let Some(conn) = &mut self.output_conns[slot] {
|
||||
let _ = conn.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_note_on(&mut self, device: u8, channel: u8, note: u8, velocity: u8) {
|
||||
let status = 0x90 | (channel & 0x0F);
|
||||
self.send_message(device, &[status, note & 0x7F, velocity & 0x7F]);
|
||||
}
|
||||
|
||||
pub fn send_note_off(&mut self, device: u8, channel: u8, note: u8) {
|
||||
let status = 0x80 | (channel & 0x0F);
|
||||
self.send_message(device, &[status, note & 0x7F, 0]);
|
||||
}
|
||||
|
||||
pub fn send_cc(&mut self, device: u8, channel: u8, cc: u8, value: u8) {
|
||||
let status = 0xB0 | (channel & 0x0F);
|
||||
self.send_message(device, &[status, cc & 0x7F, value & 0x7F]);
|
||||
}
|
||||
|
||||
pub fn send_pitch_bend(&mut self, device: u8, channel: u8, value: u16) {
|
||||
let status = 0xE0 | (channel & 0x0F);
|
||||
let lsb = (value & 0x7F) as u8;
|
||||
let msb = ((value >> 7) & 0x7F) as u8;
|
||||
self.send_message(device, &[status, lsb, msb]);
|
||||
}
|
||||
|
||||
pub fn send_pressure(&mut self, device: u8, channel: u8, value: u8) {
|
||||
let status = 0xD0 | (channel & 0x0F);
|
||||
self.send_message(device, &[status, value & 0x7F]);
|
||||
}
|
||||
|
||||
pub fn send_program_change(&mut self, device: u8, channel: u8, program: u8) {
|
||||
let status = 0xC0 | (channel & 0x0F);
|
||||
self.send_message(device, &[status, program & 0x7F]);
|
||||
}
|
||||
|
||||
pub fn send_realtime(&mut self, device: u8, msg: u8) {
|
||||
self.send_message(device, &[msg]);
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,7 @@ pub use cagire_project::{
|
||||
load, save, Bank, LaunchQuantization, Pattern, PatternSpeed, Project, SyncMode, MAX_BANKS,
|
||||
MAX_PATTERNS,
|
||||
};
|
||||
pub use script::{Dictionary, ExecutionTrace, Rng, ScriptEngine, SourceSpan, StepContext, Value, Variables};
|
||||
pub use script::{
|
||||
CcAccess, Dictionary, ExecutionTrace, Rng, ScriptEngine, SourceSpan, StepContext, Value,
|
||||
Variables,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use cagire_forth::Forth;
|
||||
|
||||
pub use cagire_forth::{Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables};
|
||||
pub use cagire_forth::{
|
||||
CcAccess, Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables,
|
||||
};
|
||||
|
||||
pub struct ScriptEngine {
|
||||
forth: Forth,
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
use crate::model::{PatternSpeed, Project};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PatternChange {
|
||||
pub struct PatternEdit {
|
||||
pub bank: usize,
|
||||
pub pattern: usize,
|
||||
}
|
||||
|
||||
impl PatternChange {
|
||||
impl PatternEdit {
|
||||
pub fn new(bank: usize, pattern: usize) -> Self {
|
||||
Self { bank, pattern }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_step(
|
||||
project: &mut Project,
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
step: usize,
|
||||
) -> PatternChange {
|
||||
pub fn toggle_step(project: &mut Project, bank: usize, pattern: usize, step: usize) -> PatternEdit {
|
||||
if let Some(s) = project.pattern_at_mut(bank, pattern).step_mut(step) {
|
||||
s.active = !s.active;
|
||||
}
|
||||
PatternChange::new(bank, pattern)
|
||||
PatternEdit::new(bank, pattern)
|
||||
}
|
||||
|
||||
pub fn set_length(
|
||||
@@ -29,30 +24,22 @@ pub fn set_length(
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
length: usize,
|
||||
) -> (PatternChange, usize) {
|
||||
) -> (PatternEdit, usize) {
|
||||
project.pattern_at_mut(bank, pattern).set_length(length);
|
||||
let actual = project.pattern_at(bank, pattern).length;
|
||||
(PatternChange::new(bank, pattern), actual)
|
||||
(PatternEdit::new(bank, pattern), actual)
|
||||
}
|
||||
|
||||
pub fn get_length(project: &Project, bank: usize, pattern: usize) -> usize {
|
||||
project.pattern_at(bank, pattern).length
|
||||
}
|
||||
|
||||
pub fn increase_length(
|
||||
project: &mut Project,
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
) -> (PatternChange, usize) {
|
||||
pub fn increase_length(project: &mut Project, bank: usize, pattern: usize) -> (PatternEdit, usize) {
|
||||
let current = get_length(project, bank, pattern);
|
||||
set_length(project, bank, pattern, current + 1)
|
||||
}
|
||||
|
||||
pub fn decrease_length(
|
||||
project: &mut Project,
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
) -> (PatternChange, usize) {
|
||||
pub fn decrease_length(project: &mut Project, bank: usize, pattern: usize) -> (PatternEdit, usize) {
|
||||
let current = get_length(project, bank, pattern);
|
||||
set_length(project, bank, pattern, current.saturating_sub(1))
|
||||
}
|
||||
@@ -62,21 +49,21 @@ pub fn set_speed(
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
speed: PatternSpeed,
|
||||
) -> PatternChange {
|
||||
) -> PatternEdit {
|
||||
project.pattern_at_mut(bank, pattern).speed = speed;
|
||||
PatternChange::new(bank, pattern)
|
||||
PatternEdit::new(bank, pattern)
|
||||
}
|
||||
|
||||
pub fn increase_speed(project: &mut Project, bank: usize, pattern: usize) -> PatternChange {
|
||||
pub fn increase_speed(project: &mut Project, bank: usize, pattern: usize) -> PatternEdit {
|
||||
let pat = project.pattern_at_mut(bank, pattern);
|
||||
pat.speed = pat.speed.next();
|
||||
PatternChange::new(bank, pattern)
|
||||
PatternEdit::new(bank, pattern)
|
||||
}
|
||||
|
||||
pub fn decrease_speed(project: &mut Project, bank: usize, pattern: usize) -> PatternChange {
|
||||
pub fn decrease_speed(project: &mut Project, bank: usize, pattern: usize) -> PatternEdit {
|
||||
let pat = project.pattern_at_mut(bank, pattern);
|
||||
pat.speed = pat.speed.prev();
|
||||
PatternChange::new(bank, pattern)
|
||||
PatternEdit::new(bank, pattern)
|
||||
}
|
||||
|
||||
pub fn set_step_script(
|
||||
@@ -85,11 +72,11 @@ pub fn set_step_script(
|
||||
pattern: usize,
|
||||
step: usize,
|
||||
script: String,
|
||||
) -> PatternChange {
|
||||
) -> PatternEdit {
|
||||
if let Some(s) = project.pattern_at_mut(bank, pattern).step_mut(step) {
|
||||
s.script = script;
|
||||
}
|
||||
PatternChange::new(bank, pattern)
|
||||
PatternEdit::new(bank, pattern)
|
||||
}
|
||||
|
||||
pub fn get_step_script(
|
||||
|
||||
@@ -4,11 +4,21 @@ use crate::state::ColorScheme;
|
||||
|
||||
const APP_NAME: &str = "cagire";
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct MidiSettings {
|
||||
#[serde(default)]
|
||||
pub output_devices: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub input_devices: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub audio: AudioSettings,
|
||||
pub display: DisplaySettings,
|
||||
pub link: LinkSettings,
|
||||
#[serde(default)]
|
||||
pub midi: MidiSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use doux::audio::AudioDeviceInfo;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::CyclicEnum;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum RefreshRate {
|
||||
#[default]
|
||||
@@ -128,6 +130,10 @@ pub enum EngineSection {
|
||||
Samples,
|
||||
}
|
||||
|
||||
impl CyclicEnum for EngineSection {
|
||||
const VARIANTS: &'static [Self] = &[Self::Devices, Self::Settings, Self::Samples];
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DeviceKind {
|
||||
#[default]
|
||||
@@ -145,26 +151,14 @@ pub enum SettingKind {
|
||||
Lookahead,
|
||||
}
|
||||
|
||||
impl SettingKind {
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Channels => Self::BufferSize,
|
||||
Self::BufferSize => Self::Polyphony,
|
||||
Self::Polyphony => Self::Nudge,
|
||||
Self::Nudge => Self::Lookahead,
|
||||
Self::Lookahead => Self::Channels,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev(self) -> Self {
|
||||
match self {
|
||||
Self::Channels => Self::Lookahead,
|
||||
Self::BufferSize => Self::Channels,
|
||||
Self::Polyphony => Self::BufferSize,
|
||||
Self::Nudge => Self::Polyphony,
|
||||
Self::Lookahead => Self::Nudge,
|
||||
}
|
||||
}
|
||||
impl CyclicEnum for SettingKind {
|
||||
const VARIANTS: &'static [Self] = &[
|
||||
Self::Channels,
|
||||
Self::BufferSize,
|
||||
Self::Polyphony,
|
||||
Self::Nudge,
|
||||
Self::Lookahead,
|
||||
];
|
||||
}
|
||||
|
||||
pub struct Metrics {
|
||||
@@ -242,19 +236,11 @@ impl AudioSettings {
|
||||
}
|
||||
|
||||
pub fn next_section(&mut self) {
|
||||
self.section = match self.section {
|
||||
EngineSection::Devices => EngineSection::Settings,
|
||||
EngineSection::Settings => EngineSection::Samples,
|
||||
EngineSection::Samples => EngineSection::Devices,
|
||||
};
|
||||
self.section = self.section.next();
|
||||
}
|
||||
|
||||
pub fn prev_section(&mut self) {
|
||||
self.section = match self.section {
|
||||
EngineSection::Devices => EngineSection::Samples,
|
||||
EngineSection::Settings => EngineSection::Devices,
|
||||
EngineSection::Samples => EngineSection::Settings,
|
||||
};
|
||||
self.section = self.section.prev();
|
||||
}
|
||||
|
||||
pub fn current_output_device_index(&self) -> usize {
|
||||
|
||||
@@ -1,65 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::theme::ThemeColors;
|
||||
use crate::theme::{ThemeColors, THEMES};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum ColorScheme {
|
||||
#[default]
|
||||
CatppuccinMocha,
|
||||
CatppuccinLatte,
|
||||
Nord,
|
||||
Dracula,
|
||||
GruvboxDark,
|
||||
Monokai,
|
||||
PitchBlack,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ColorScheme(usize);
|
||||
|
||||
impl ColorScheme {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::CatppuccinMocha => "Catppuccin Mocha",
|
||||
Self::CatppuccinLatte => "Catppuccin Latte",
|
||||
Self::Nord => "Nord",
|
||||
Self::Dracula => "Dracula",
|
||||
Self::GruvboxDark => "Gruvbox Dark",
|
||||
Self::Monokai => "Monokai",
|
||||
Self::PitchBlack => "Pitch Black",
|
||||
}
|
||||
THEMES[self.0].label
|
||||
}
|
||||
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Self::CatppuccinMocha => Self::CatppuccinLatte,
|
||||
Self::CatppuccinLatte => Self::Nord,
|
||||
Self::Nord => Self::Dracula,
|
||||
Self::Dracula => Self::GruvboxDark,
|
||||
Self::GruvboxDark => Self::Monokai,
|
||||
Self::Monokai => Self::PitchBlack,
|
||||
Self::PitchBlack => Self::CatppuccinMocha,
|
||||
}
|
||||
Self((self.0 + 1) % THEMES.len())
|
||||
}
|
||||
|
||||
pub fn prev(self) -> Self {
|
||||
match self {
|
||||
Self::CatppuccinMocha => Self::PitchBlack,
|
||||
Self::CatppuccinLatte => Self::CatppuccinMocha,
|
||||
Self::Nord => Self::CatppuccinLatte,
|
||||
Self::Dracula => Self::Nord,
|
||||
Self::GruvboxDark => Self::Dracula,
|
||||
Self::Monokai => Self::GruvboxDark,
|
||||
Self::PitchBlack => Self::Monokai,
|
||||
}
|
||||
Self((self.0 + THEMES.len() - 1) % THEMES.len())
|
||||
}
|
||||
|
||||
pub fn to_theme(self) -> ThemeColors {
|
||||
match self {
|
||||
Self::CatppuccinMocha => ThemeColors::catppuccin_mocha(),
|
||||
Self::CatppuccinLatte => ThemeColors::catppuccin_latte(),
|
||||
Self::Nord => ThemeColors::nord(),
|
||||
Self::Dracula => ThemeColors::dracula(),
|
||||
Self::GruvboxDark => ThemeColors::gruvbox_dark(),
|
||||
Self::Monokai => ThemeColors::monokai(),
|
||||
Self::PitchBlack => ThemeColors::pitch_black(),
|
||||
}
|
||||
(THEMES[self.0].colors)()
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ColorScheme {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(THEMES[self.0].id)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ColorScheme {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
THEMES
|
||||
.iter()
|
||||
.position(|t| t.id == s)
|
||||
.map(ColorScheme)
|
||||
.ok_or_else(|| de::Error::custom(format!("unknown theme: {s}")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,6 @@ use std::ops::RangeInclusive;
|
||||
|
||||
use cagire_ratatui::Editor;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Focus {
|
||||
Sequencer,
|
||||
Editor,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PatternField {
|
||||
Length,
|
||||
@@ -51,7 +45,6 @@ pub struct EditorContext {
|
||||
pub bank: usize,
|
||||
pub pattern: usize,
|
||||
pub step: usize,
|
||||
pub focus: Focus,
|
||||
pub editor: Editor,
|
||||
pub selection_anchor: Option<usize>,
|
||||
pub copied_steps: Option<CopiedSteps>,
|
||||
@@ -101,7 +94,6 @@ impl Default for EditorContext {
|
||||
bank: 0,
|
||||
pattern: 0,
|
||||
step: 0,
|
||||
focus: Focus::Sequencer,
|
||||
editor: Editor::new(),
|
||||
selection_anchor: None,
|
||||
copied_steps: None,
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
pub trait CyclicEnum: Sized + Copy + PartialEq + 'static {
|
||||
const VARIANTS: &'static [Self];
|
||||
|
||||
fn next(self) -> Self {
|
||||
let pos = Self::VARIANTS.iter().position(|v| *v == self).unwrap_or(0);
|
||||
Self::VARIANTS[(pos + 1) % Self::VARIANTS.len()]
|
||||
}
|
||||
|
||||
fn prev(self) -> Self {
|
||||
let len = Self::VARIANTS.len();
|
||||
let pos = Self::VARIANTS.iter().position(|v| *v == self).unwrap_or(0);
|
||||
Self::VARIANTS[(pos + len - 1) % len]
|
||||
}
|
||||
}
|
||||
|
||||
pub mod audio;
|
||||
pub mod color_scheme;
|
||||
pub mod editor;
|
||||
@@ -14,10 +29,12 @@ pub mod ui;
|
||||
|
||||
pub use audio::{AudioSettings, DeviceKind, EngineSection, Metrics, SettingKind};
|
||||
pub use color_scheme::ColorScheme;
|
||||
pub use options::{OptionsFocus, OptionsState};
|
||||
pub use editor::{CopiedStepData, CopiedSteps, EditorContext, Focus, PatternField, PatternPropsField, StackCache};
|
||||
pub use editor::{
|
||||
CopiedStepData, CopiedSteps, EditorContext, PatternField, PatternPropsField, StackCache,
|
||||
};
|
||||
pub use live_keys::LiveKeyState;
|
||||
pub use modal::Modal;
|
||||
pub use options::{OptionsFocus, OptionsState};
|
||||
pub use panel::{PanelFocus, PanelState, SidePanel};
|
||||
pub use patterns_nav::{PatternsColumn, PatternsNav};
|
||||
pub use playback::{PlaybackState, StagedChange};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use super::CyclicEnum;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum OptionsFocus {
|
||||
#[default]
|
||||
@@ -11,6 +13,37 @@ pub enum OptionsFocus {
|
||||
LinkEnabled,
|
||||
StartStopSync,
|
||||
Quantum,
|
||||
MidiOutput0,
|
||||
MidiOutput1,
|
||||
MidiOutput2,
|
||||
MidiOutput3,
|
||||
MidiInput0,
|
||||
MidiInput1,
|
||||
MidiInput2,
|
||||
MidiInput3,
|
||||
}
|
||||
|
||||
impl CyclicEnum for OptionsFocus {
|
||||
const VARIANTS: &'static [Self] = &[
|
||||
Self::ColorScheme,
|
||||
Self::RefreshRate,
|
||||
Self::RuntimeHighlight,
|
||||
Self::ShowScope,
|
||||
Self::ShowSpectrum,
|
||||
Self::ShowCompletion,
|
||||
Self::FlashBrightness,
|
||||
Self::LinkEnabled,
|
||||
Self::StartStopSync,
|
||||
Self::Quantum,
|
||||
Self::MidiOutput0,
|
||||
Self::MidiOutput1,
|
||||
Self::MidiOutput2,
|
||||
Self::MidiOutput3,
|
||||
Self::MidiInput0,
|
||||
Self::MidiInput1,
|
||||
Self::MidiInput2,
|
||||
Self::MidiInput3,
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -20,32 +53,10 @@ pub struct OptionsState {
|
||||
|
||||
impl OptionsState {
|
||||
pub fn next_focus(&mut self) {
|
||||
self.focus = match self.focus {
|
||||
OptionsFocus::ColorScheme => OptionsFocus::RefreshRate,
|
||||
OptionsFocus::RefreshRate => OptionsFocus::RuntimeHighlight,
|
||||
OptionsFocus::RuntimeHighlight => OptionsFocus::ShowScope,
|
||||
OptionsFocus::ShowScope => OptionsFocus::ShowSpectrum,
|
||||
OptionsFocus::ShowSpectrum => OptionsFocus::ShowCompletion,
|
||||
OptionsFocus::ShowCompletion => OptionsFocus::FlashBrightness,
|
||||
OptionsFocus::FlashBrightness => OptionsFocus::LinkEnabled,
|
||||
OptionsFocus::LinkEnabled => OptionsFocus::StartStopSync,
|
||||
OptionsFocus::StartStopSync => OptionsFocus::Quantum,
|
||||
OptionsFocus::Quantum => OptionsFocus::ColorScheme,
|
||||
};
|
||||
self.focus = self.focus.next();
|
||||
}
|
||||
|
||||
pub fn prev_focus(&mut self) {
|
||||
self.focus = match self.focus {
|
||||
OptionsFocus::ColorScheme => OptionsFocus::Quantum,
|
||||
OptionsFocus::RefreshRate => OptionsFocus::ColorScheme,
|
||||
OptionsFocus::RuntimeHighlight => OptionsFocus::RefreshRate,
|
||||
OptionsFocus::ShowScope => OptionsFocus::RuntimeHighlight,
|
||||
OptionsFocus::ShowSpectrum => OptionsFocus::ShowScope,
|
||||
OptionsFocus::ShowCompletion => OptionsFocus::ShowSpectrum,
|
||||
OptionsFocus::FlashBrightness => OptionsFocus::ShowCompletion,
|
||||
OptionsFocus::LinkEnabled => OptionsFocus::FlashBrightness,
|
||||
OptionsFocus::StartStopSync => OptionsFocus::LinkEnabled,
|
||||
OptionsFocus::Quantum => OptionsFocus::StartStopSync,
|
||||
};
|
||||
self.focus = self.focus.prev();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,9 @@ impl PlaybackState {
|
||||
pub fn toggle(&mut self) {
|
||||
self.playing = !self.playing;
|
||||
}
|
||||
|
||||
pub fn clear_queues(&mut self) {
|
||||
self.staged_changes.clear();
|
||||
self.queued_changes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ pub struct UiState {
|
||||
pub help_search_query: String,
|
||||
pub dict_focus: DictFocus,
|
||||
pub dict_category: usize,
|
||||
pub dict_scroll: usize,
|
||||
pub dict_scrolls: Vec<usize>,
|
||||
pub dict_search_query: String,
|
||||
pub dict_search_active: bool,
|
||||
pub show_title: bool,
|
||||
@@ -67,7 +67,7 @@ impl Default for UiState {
|
||||
help_search_query: String::new(),
|
||||
dict_focus: DictFocus::default(),
|
||||
dict_category: 0,
|
||||
dict_scroll: 0,
|
||||
dict_scrolls: vec![0; crate::views::dict_view::category_count()],
|
||||
dict_search_query: String::new(),
|
||||
dict_search_active: false,
|
||||
show_title: true,
|
||||
@@ -91,6 +91,14 @@ impl UiState {
|
||||
&mut self.help_scrolls[self.help_topic]
|
||||
}
|
||||
|
||||
pub fn dict_scroll(&self) -> usize {
|
||||
self.dict_scrolls[self.dict_category]
|
||||
}
|
||||
|
||||
pub fn dict_scroll_mut(&mut self) -> &mut usize {
|
||||
&mut self.dict_scrolls[self.dict_category]
|
||||
}
|
||||
|
||||
pub fn flash(&mut self, msg: &str, duration_ms: u64, kind: FlashKind) {
|
||||
self.status_message = Some(msg.to_string());
|
||||
self.flash_until = Some(Instant::now() + Duration::from_millis(duration_ms));
|
||||
|
||||
@@ -9,36 +9,52 @@ use crate::model::{Word, WORDS};
|
||||
use crate::state::DictFocus;
|
||||
use crate::theme;
|
||||
|
||||
const CATEGORIES: &[&str] = &[
|
||||
enum CatEntry {
|
||||
Section(&'static str),
|
||||
Category(&'static str),
|
||||
}
|
||||
|
||||
use CatEntry::{Category, Section};
|
||||
|
||||
const CATEGORIES: &[CatEntry] = &[
|
||||
// Forth core
|
||||
"Stack",
|
||||
"Arithmetic",
|
||||
"Comparison",
|
||||
"Logic",
|
||||
"Variables",
|
||||
"Randomness",
|
||||
"Probability",
|
||||
"Lists",
|
||||
"Definitions",
|
||||
Section("Forth"),
|
||||
Category("Stack"),
|
||||
Category("Arithmetic"),
|
||||
Category("Comparison"),
|
||||
Category("Logic"),
|
||||
Category("Control"),
|
||||
Category("Variables"),
|
||||
Category("Probability"),
|
||||
Category("Definitions"),
|
||||
// Live coding
|
||||
"Sound",
|
||||
"Time",
|
||||
"Context",
|
||||
"Music",
|
||||
"LFO",
|
||||
Section("Live Coding"),
|
||||
Category("Sound"),
|
||||
Category("Time"),
|
||||
Category("Context"),
|
||||
Category("Music"),
|
||||
Category("LFO"),
|
||||
// Synthesis
|
||||
"Oscillator",
|
||||
"Envelope",
|
||||
"Pitch Env",
|
||||
"Gain",
|
||||
"Sample",
|
||||
Section("Synthesis"),
|
||||
Category("Oscillator"),
|
||||
Category("Wavetable"),
|
||||
Category("Generator"),
|
||||
Category("Envelope"),
|
||||
Category("Sample"),
|
||||
// Effects
|
||||
"Filter",
|
||||
"Modulation",
|
||||
"Mod FX",
|
||||
"Lo-fi",
|
||||
"Delay",
|
||||
"Reverb",
|
||||
Section("Effects"),
|
||||
Category("Filter"),
|
||||
Category("FM"),
|
||||
Category("Modulation"),
|
||||
Category("Mod FX"),
|
||||
Category("Lo-fi"),
|
||||
Category("Stereo"),
|
||||
Category("Delay"),
|
||||
Category("Reverb"),
|
||||
// External I/O
|
||||
Section("I/O"),
|
||||
Category("MIDI"),
|
||||
Category("Desktop"),
|
||||
];
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
||||
@@ -76,22 +92,67 @@ fn render_categories(frame: &mut Frame, app: &App, area: Rect, dimmed: bool) {
|
||||
let theme = theme::get();
|
||||
let focused = app.ui.dict_focus == DictFocus::Categories && !dimmed;
|
||||
|
||||
let visible_height = area.height.saturating_sub(2) as usize;
|
||||
let total_items = CATEGORIES.len();
|
||||
|
||||
// Find the visual index of the selected category (including sections)
|
||||
let selected_visual_idx = {
|
||||
let mut visual = 0;
|
||||
let mut cat_count = 0;
|
||||
for entry in CATEGORIES.iter() {
|
||||
if let Category(_) = entry {
|
||||
if cat_count == app.ui.dict_category {
|
||||
break;
|
||||
}
|
||||
cat_count += 1;
|
||||
}
|
||||
visual += 1;
|
||||
}
|
||||
visual
|
||||
};
|
||||
|
||||
// Calculate scroll to keep selection visible (centered when possible)
|
||||
let scroll = if selected_visual_idx < visible_height / 2 {
|
||||
0
|
||||
} else if selected_visual_idx > total_items.saturating_sub(visible_height / 2) {
|
||||
total_items.saturating_sub(visible_height)
|
||||
} else {
|
||||
selected_visual_idx.saturating_sub(visible_height / 2)
|
||||
};
|
||||
|
||||
// Count categories before the scroll offset to track cat_idx correctly
|
||||
let mut cat_idx = CATEGORIES
|
||||
.iter()
|
||||
.take(scroll)
|
||||
.filter(|e| matches!(e, Category(_)))
|
||||
.count();
|
||||
|
||||
let items: Vec<ListItem> = CATEGORIES
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
let is_selected = i == app.ui.dict_category;
|
||||
let style = if dimmed {
|
||||
Style::new().fg(theme.dict.category_dimmed)
|
||||
} else if is_selected && focused {
|
||||
Style::new().fg(theme.dict.category_focused).add_modifier(Modifier::BOLD)
|
||||
} else if is_selected {
|
||||
Style::new().fg(theme.dict.category_selected)
|
||||
} else {
|
||||
Style::new().fg(theme.dict.category_normal)
|
||||
};
|
||||
let prefix = if is_selected && !dimmed { "> " } else { " " };
|
||||
ListItem::new(format!("{prefix}{name}")).style(style)
|
||||
.skip(scroll)
|
||||
.take(visible_height)
|
||||
.map(|entry| match entry {
|
||||
Section(name) => {
|
||||
let style = Style::new().fg(theme.ui.text_dim);
|
||||
ListItem::new(format!("─ {name} ─")).style(style)
|
||||
}
|
||||
Category(name) => {
|
||||
let is_selected = cat_idx == app.ui.dict_category;
|
||||
let style = if dimmed {
|
||||
Style::new().fg(theme.dict.category_dimmed)
|
||||
} else if is_selected && focused {
|
||||
Style::new()
|
||||
.fg(theme.dict.category_focused)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_selected {
|
||||
Style::new().fg(theme.dict.category_selected)
|
||||
} else {
|
||||
Style::new().fg(theme.dict.category_normal)
|
||||
};
|
||||
let prefix = if is_selected && !dimmed { "> " } else { " " };
|
||||
cat_idx += 1;
|
||||
ListItem::new(format!("{prefix}{name}")).style(style)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -104,6 +165,17 @@ fn render_categories(frame: &mut Frame, app: &App, area: Rect, dimmed: bool) {
|
||||
frame.render_widget(list, area);
|
||||
}
|
||||
|
||||
fn get_category_name(index: usize) -> &'static str {
|
||||
CATEGORIES
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Category(name) => Some(*name),
|
||||
Section(_) => None,
|
||||
})
|
||||
.nth(index)
|
||||
.unwrap_or("Unknown")
|
||||
}
|
||||
|
||||
fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) {
|
||||
let theme = theme::get();
|
||||
let focused = app.ui.dict_focus == DictFocus::Words;
|
||||
@@ -119,7 +191,7 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) {
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
let category = CATEGORIES[app.ui.dict_category];
|
||||
let category = get_category_name(app.ui.dict_category);
|
||||
WORDS
|
||||
.iter()
|
||||
.filter(|w| w.category == category)
|
||||
@@ -195,18 +267,12 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) {
|
||||
let visible_height = content_area.height.saturating_sub(2) as usize;
|
||||
let total_lines = lines.len();
|
||||
let max_scroll = total_lines.saturating_sub(visible_height);
|
||||
let scroll = app.ui.dict_scroll.min(max_scroll);
|
||||
|
||||
let visible: Vec<RLine> = lines
|
||||
.into_iter()
|
||||
.skip(scroll)
|
||||
.take(visible_height)
|
||||
.collect();
|
||||
let scroll = app.ui.dict_scroll().min(max_scroll);
|
||||
|
||||
let title = if is_searching {
|
||||
format!("Search: {} matches", words.len())
|
||||
} else {
|
||||
let category = CATEGORIES[app.ui.dict_category];
|
||||
let category = get_category_name(app.ui.dict_category);
|
||||
format!("{category} ({} words)", words.len())
|
||||
};
|
||||
let border_color = if focused { theme.dict.border_focused } else { theme.dict.border_normal };
|
||||
@@ -214,7 +280,9 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) {
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::new().fg(border_color))
|
||||
.title(title);
|
||||
let para = Paragraph::new(visible).block(block);
|
||||
let para = Paragraph::new(lines)
|
||||
.scroll((scroll as u16, 0))
|
||||
.block(block);
|
||||
frame.render_widget(para, content_area);
|
||||
}
|
||||
|
||||
@@ -232,5 +300,8 @@ fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) {
|
||||
}
|
||||
|
||||
pub fn category_count() -> usize {
|
||||
CATEGORIES.len()
|
||||
CATEGORIES
|
||||
.iter()
|
||||
.filter(|e| matches!(e, Category(_)))
|
||||
.count()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use minimad::{Composite, CompositeStyle, Compound, Line, TableRow};
|
||||
use cagire_markdown::{CodeHighlighter, MarkdownTheme};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line as RLine, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, Padding, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
@@ -11,6 +11,78 @@ use crate::state::HelpFocus;
|
||||
use crate::theme;
|
||||
use crate::views::highlight;
|
||||
|
||||
struct AppTheme;
|
||||
|
||||
impl MarkdownTheme for AppTheme {
|
||||
fn h1(&self) -> Style {
|
||||
Style::new()
|
||||
.fg(theme::get().markdown.h1)
|
||||
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
|
||||
}
|
||||
|
||||
fn h2(&self) -> Style {
|
||||
Style::new()
|
||||
.fg(theme::get().markdown.h2)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
fn h3(&self) -> Style {
|
||||
Style::new()
|
||||
.fg(theme::get().markdown.h3)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
fn text(&self) -> Style {
|
||||
Style::new().fg(theme::get().markdown.text)
|
||||
}
|
||||
|
||||
fn code(&self) -> Style {
|
||||
Style::new().fg(theme::get().markdown.code)
|
||||
}
|
||||
|
||||
fn code_border(&self) -> Style {
|
||||
Style::new().fg(theme::get().markdown.code_border)
|
||||
}
|
||||
|
||||
fn link(&self) -> Style {
|
||||
Style::new()
|
||||
.fg(theme::get().markdown.link)
|
||||
.add_modifier(Modifier::UNDERLINED)
|
||||
}
|
||||
|
||||
fn link_url(&self) -> Style {
|
||||
Style::new().fg(theme::get().markdown.link_url)
|
||||
}
|
||||
|
||||
fn quote(&self) -> Style {
|
||||
Style::new().fg(theme::get().markdown.quote)
|
||||
}
|
||||
|
||||
fn list(&self) -> Style {
|
||||
Style::new().fg(theme::get().markdown.list)
|
||||
}
|
||||
|
||||
fn table_header_bg(&self) -> Color {
|
||||
theme::get().ui.surface
|
||||
}
|
||||
|
||||
fn table_row_even(&self) -> Color {
|
||||
theme::get().table.row_even
|
||||
}
|
||||
|
||||
fn table_row_odd(&self) -> Color {
|
||||
theme::get().table.row_odd
|
||||
}
|
||||
}
|
||||
|
||||
struct ForthHighlighter;
|
||||
|
||||
impl CodeHighlighter for ForthHighlighter {
|
||||
fn highlight(&self, line: &str) -> Vec<(Style, String)> {
|
||||
highlight::highlight_line(line)
|
||||
}
|
||||
}
|
||||
|
||||
enum DocEntry {
|
||||
Section(&'static str),
|
||||
Topic(&'static str, &'static str),
|
||||
@@ -59,17 +131,11 @@ const DOCS: &[DocEntry] = &[
|
||||
),
|
||||
Topic("Space & Time", include_str!("../../docs/engine_space.md")),
|
||||
Topic("Words & Sounds", include_str!("../../docs/engine_words.md")),
|
||||
// Reference
|
||||
Section("Reference"),
|
||||
Topic("Audio Engine", include_str!("../../docs/audio_engine.md")),
|
||||
Topic("Keybindings", include_str!("../../docs/keybindings.md")),
|
||||
Topic("Sequencer", include_str!("../../docs/sequencer.md")),
|
||||
// Archive - old files to sort
|
||||
Section("Archive"),
|
||||
Topic("Sound Basics", include_str!("../../docs/sound_basics.md")),
|
||||
Topic("Parameters", include_str!("../../docs/parameters.md")),
|
||||
Topic("Tempo & Speed", include_str!("../../docs/tempo.md")),
|
||||
Topic("Effects (old)", include_str!("../../docs/effects.md")),
|
||||
// MIDI
|
||||
Section("MIDI"),
|
||||
Topic("Introduction", include_str!("../../docs/midi_intro.md")),
|
||||
Topic("MIDI Output", include_str!("../../docs/midi_output.md")),
|
||||
Topic("MIDI Input", include_str!("../../docs/midi_input.md")),
|
||||
];
|
||||
|
||||
pub fn topic_count() -> usize {
|
||||
@@ -208,7 +274,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let has_query = !query.is_empty();
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let lines = parse_markdown(md);
|
||||
let lines = cagire_markdown::parse(md, &AppTheme, &ForthHighlighter);
|
||||
|
||||
let has_search_bar = app.ui.help_search_active || has_query;
|
||||
let content_area = if has_search_bar {
|
||||
@@ -265,7 +331,7 @@ fn wrapped_line_count(line: &RLine, width: usize) -> usize {
|
||||
if char_count == 0 || width == 0 {
|
||||
1
|
||||
} else {
|
||||
(char_count + width - 1) / width
|
||||
char_count.div_ceil(width)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,285 +405,3 @@ pub fn find_match(query: &str) -> Option<(usize, usize)> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn code_border_style() -> Style {
|
||||
let theme = theme::get();
|
||||
Style::new().fg(theme.markdown.code_border)
|
||||
}
|
||||
|
||||
fn preprocess_markdown(md: &str) -> String {
|
||||
let mut out = String::with_capacity(md.len());
|
||||
for line in md.lines() {
|
||||
// Convert dash list markers to asterisks (minimad only recognizes *)
|
||||
let line = convert_dash_lists(line);
|
||||
let mut result = String::with_capacity(line.len());
|
||||
let mut chars = line.char_indices().peekable();
|
||||
let bytes = line.as_bytes();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
if c == '`' {
|
||||
result.push(c);
|
||||
for (_, ch) in chars.by_ref() {
|
||||
result.push(ch);
|
||||
if ch == '`' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == '_' {
|
||||
let before_is_space = i == 0 || bytes[i - 1] == b' ';
|
||||
if before_is_space {
|
||||
if let Some(end) = line[i + 1..].find('_') {
|
||||
let inner = &line[i + 1..i + 1 + end];
|
||||
if !inner.is_empty() {
|
||||
result.push('*');
|
||||
result.push_str(inner);
|
||||
result.push('*');
|
||||
for _ in 0..end {
|
||||
chars.next();
|
||||
}
|
||||
chars.next(); // skip closing _
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push(c);
|
||||
}
|
||||
out.push_str(&result);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn convert_dash_lists(line: &str) -> String {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with("- ") {
|
||||
let indent = line.len() - trimmed.len();
|
||||
format!("{}* {}", " ".repeat(indent), &trimmed[2..])
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_markdown(md: &str) -> Vec<RLine<'static>> {
|
||||
let processed = preprocess_markdown(md);
|
||||
let text = minimad::Text::from(processed.as_str());
|
||||
let mut lines = Vec::new();
|
||||
|
||||
let mut code_line_nr: usize = 0;
|
||||
let mut table_buffer: Vec<TableRow> = Vec::new();
|
||||
|
||||
let flush_table = |buf: &mut Vec<TableRow>, out: &mut Vec<RLine<'static>>| {
|
||||
if buf.is_empty() {
|
||||
return;
|
||||
}
|
||||
let col_widths = compute_column_widths(buf);
|
||||
for (row_idx, row) in buf.drain(..).enumerate() {
|
||||
out.push(render_table_row(row, row_idx, &col_widths));
|
||||
}
|
||||
};
|
||||
|
||||
for line in text.lines {
|
||||
match line {
|
||||
Line::Normal(composite) if composite.style == CompositeStyle::Code => {
|
||||
flush_table(&mut table_buffer, &mut lines);
|
||||
code_line_nr += 1;
|
||||
let raw: String = composite
|
||||
.compounds
|
||||
.iter()
|
||||
.map(|c: &minimad::Compound| c.src)
|
||||
.collect();
|
||||
let mut spans = vec![
|
||||
Span::styled(format!(" {code_line_nr:>2} "), code_border_style()),
|
||||
Span::styled("│ ", code_border_style()),
|
||||
];
|
||||
spans.extend(
|
||||
highlight::highlight_line(&raw)
|
||||
.into_iter()
|
||||
.map(|(style, text)| Span::styled(text, style)),
|
||||
);
|
||||
lines.push(RLine::from(spans));
|
||||
}
|
||||
Line::Normal(composite) => {
|
||||
flush_table(&mut table_buffer, &mut lines);
|
||||
code_line_nr = 0;
|
||||
lines.push(composite_to_line(composite));
|
||||
}
|
||||
Line::TableRow(row) => {
|
||||
code_line_nr = 0;
|
||||
table_buffer.push(row);
|
||||
}
|
||||
Line::TableRule(_) => {
|
||||
// Skip the separator line (---|---|---)
|
||||
}
|
||||
_ => {
|
||||
flush_table(&mut table_buffer, &mut lines);
|
||||
code_line_nr = 0;
|
||||
lines.push(RLine::from(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
flush_table(&mut table_buffer, &mut lines);
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn cell_text_width(cell: &Composite) -> usize {
|
||||
cell.compounds.iter().map(|c| c.src.chars().count()).sum()
|
||||
}
|
||||
|
||||
fn compute_column_widths(rows: &[TableRow]) -> Vec<usize> {
|
||||
let mut widths: Vec<usize> = Vec::new();
|
||||
for row in rows {
|
||||
for (i, cell) in row.cells.iter().enumerate() {
|
||||
let w = cell_text_width(cell);
|
||||
if i >= widths.len() {
|
||||
widths.push(w);
|
||||
} else if w > widths[i] {
|
||||
widths[i] = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
widths
|
||||
}
|
||||
|
||||
fn render_table_row(row: TableRow, row_idx: usize, col_widths: &[usize]) -> RLine<'static> {
|
||||
let theme = theme::get();
|
||||
let is_header = row_idx == 0;
|
||||
let bg = if is_header {
|
||||
theme.ui.surface
|
||||
} else if row_idx % 2 == 0 {
|
||||
theme.table.row_even
|
||||
} else {
|
||||
theme.table.row_odd
|
||||
};
|
||||
|
||||
let base_style = if is_header {
|
||||
Style::new()
|
||||
.fg(theme.markdown.text)
|
||||
.bg(bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::new().fg(theme.markdown.text).bg(bg)
|
||||
};
|
||||
|
||||
let sep_style = Style::new().fg(theme.markdown.code_border).bg(bg);
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
|
||||
for (i, cell) in row.cells.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
spans.push(Span::styled(" │ ", sep_style));
|
||||
}
|
||||
let target_width = col_widths.get(i).copied().unwrap_or(0);
|
||||
let cell_width = cell
|
||||
.compounds
|
||||
.iter()
|
||||
.map(|c| c.src.chars().count())
|
||||
.sum::<usize>();
|
||||
|
||||
for compound in cell.compounds {
|
||||
compound_to_spans(compound, base_style, &mut spans);
|
||||
}
|
||||
|
||||
let padding = target_width.saturating_sub(cell_width);
|
||||
if padding > 0 {
|
||||
spans.push(Span::styled(" ".repeat(padding), base_style));
|
||||
}
|
||||
}
|
||||
|
||||
RLine::from(spans)
|
||||
}
|
||||
|
||||
fn composite_to_line(composite: Composite) -> RLine<'static> {
|
||||
let theme = theme::get();
|
||||
let base_style = match composite.style {
|
||||
CompositeStyle::Header(1) => Style::new()
|
||||
.fg(theme.markdown.h1)
|
||||
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
|
||||
CompositeStyle::Header(2) => Style::new()
|
||||
.fg(theme.markdown.h2)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
CompositeStyle::Header(_) => Style::new()
|
||||
.fg(theme.markdown.h3)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
CompositeStyle::ListItem(_) => Style::new().fg(theme.markdown.list),
|
||||
CompositeStyle::Quote => Style::new().fg(theme.markdown.quote),
|
||||
CompositeStyle::Code => Style::new().fg(theme.markdown.code),
|
||||
CompositeStyle::Paragraph => Style::new().fg(theme.markdown.text),
|
||||
};
|
||||
|
||||
let prefix: String = match composite.style {
|
||||
CompositeStyle::ListItem(depth) => {
|
||||
let indent = " ".repeat(depth as usize);
|
||||
format!("{indent}• ")
|
||||
}
|
||||
CompositeStyle::Quote => " │ ".to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
if !prefix.is_empty() {
|
||||
spans.push(Span::styled(prefix, base_style));
|
||||
}
|
||||
|
||||
for compound in composite.compounds {
|
||||
compound_to_spans(compound, base_style, &mut spans);
|
||||
}
|
||||
|
||||
RLine::from(spans)
|
||||
}
|
||||
|
||||
fn compound_to_spans(compound: Compound, base: Style, out: &mut Vec<Span<'static>>) {
|
||||
let theme = theme::get();
|
||||
let mut style = base;
|
||||
|
||||
if compound.bold {
|
||||
style = style.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
if compound.italic {
|
||||
style = style.add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
if compound.code {
|
||||
style = Style::new().fg(theme.markdown.code);
|
||||
}
|
||||
if compound.strikeout {
|
||||
style = style.add_modifier(Modifier::CROSSED_OUT);
|
||||
}
|
||||
|
||||
let src = compound.src.to_string();
|
||||
let link_style = Style::new()
|
||||
.fg(theme.markdown.link)
|
||||
.add_modifier(Modifier::UNDERLINED);
|
||||
|
||||
let mut rest = src.as_str();
|
||||
while let Some(start) = rest.find('[') {
|
||||
let after_bracket = &rest[start + 1..];
|
||||
if let Some(text_end) = after_bracket.find("](") {
|
||||
let url_start = start + 1 + text_end + 2;
|
||||
if let Some(url_end) = rest[url_start..].find(')') {
|
||||
if start > 0 {
|
||||
out.push(Span::styled(rest[..start].to_string(), style));
|
||||
}
|
||||
let text = &rest[start + 1..start + 1 + text_end];
|
||||
let url = &rest[url_start..url_start + url_end];
|
||||
if text == url {
|
||||
out.push(Span::styled(url.to_string(), link_style));
|
||||
} else {
|
||||
out.push(Span::styled(text.to_string(), link_style));
|
||||
out.push(Span::styled(
|
||||
format!(" ({url})"),
|
||||
Style::new().fg(theme.markdown.link_url),
|
||||
));
|
||||
}
|
||||
rest = &rest[url_start + url_end + 1..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(Span::styled(rest[..start + 1].to_string(), style));
|
||||
rest = &rest[start + 1..];
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
out.push(Span::styled(rest.to_string(), style));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use ratatui::Frame;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::engine::LinkState;
|
||||
use crate::midi;
|
||||
use crate::state::OptionsFocus;
|
||||
use crate::theme;
|
||||
|
||||
@@ -30,7 +31,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
let focus = app.options.focus;
|
||||
let content_width = padded.width as usize;
|
||||
|
||||
// Build link header with status
|
||||
let enabled = link.is_enabled();
|
||||
let peers = link.peers();
|
||||
let (status_text, status_color) = if !enabled {
|
||||
@@ -63,7 +63,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
Span::styled(peer_text, Style::new().fg(theme.ui.text_muted)),
|
||||
]);
|
||||
|
||||
// Prepare values
|
||||
let flash_str = format!("{:.0}%", app.ui.flash_brightness * 100.0);
|
||||
let quantum_str = format!("{:.0}", link.quantum());
|
||||
let tempo_str = format!("{:.1} BPM", link.tempo());
|
||||
@@ -73,9 +72,45 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
let tempo_style = Style::new().fg(theme.values.tempo).add_modifier(Modifier::BOLD);
|
||||
let value_style = Style::new().fg(theme.values.value);
|
||||
|
||||
// Build flat list of all lines
|
||||
let midi_outputs = midi::list_midi_outputs();
|
||||
let midi_inputs = midi::list_midi_inputs();
|
||||
|
||||
let midi_out_display = |slot: usize| -> String {
|
||||
if let Some(idx) = app.midi.selected_outputs[slot] {
|
||||
midi_outputs
|
||||
.get(idx)
|
||||
.map(|d| d.name.clone())
|
||||
.unwrap_or_else(|| "(disconnected)".to_string())
|
||||
} else if midi_outputs.is_empty() {
|
||||
"(none found)".to_string()
|
||||
} else {
|
||||
"(not connected)".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let midi_in_display = |slot: usize| -> String {
|
||||
if let Some(idx) = app.midi.selected_inputs[slot] {
|
||||
midi_inputs
|
||||
.get(idx)
|
||||
.map(|d| d.name.clone())
|
||||
.unwrap_or_else(|| "(disconnected)".to_string())
|
||||
} else if midi_inputs.is_empty() {
|
||||
"(none found)".to_string()
|
||||
} else {
|
||||
"(not connected)".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let midi_out_0 = midi_out_display(0);
|
||||
let midi_out_1 = midi_out_display(1);
|
||||
let midi_out_2 = midi_out_display(2);
|
||||
let midi_out_3 = midi_out_display(3);
|
||||
let midi_in_0 = midi_in_display(0);
|
||||
let midi_in_1 = midi_in_display(1);
|
||||
let midi_in_2 = midi_in_display(2);
|
||||
let midi_in_3 = midi_in_display(3);
|
||||
|
||||
let lines: Vec<Line> = vec![
|
||||
// DISPLAY section (lines 0-8)
|
||||
render_section_header("DISPLAY", &theme),
|
||||
render_divider(content_width, &theme),
|
||||
render_option_line(
|
||||
@@ -119,9 +154,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
&theme,
|
||||
),
|
||||
render_option_line("Flash brightness", &flash_str, focus == OptionsFocus::FlashBrightness, &theme),
|
||||
// Blank line (line 9)
|
||||
Line::from(""),
|
||||
// ABLETON LINK section (lines 10-15)
|
||||
link_header,
|
||||
render_divider(content_width, &theme),
|
||||
render_option_line(
|
||||
@@ -141,20 +174,31 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
&theme,
|
||||
),
|
||||
render_option_line("Quantum", &quantum_str, focus == OptionsFocus::Quantum, &theme),
|
||||
// Blank line (line 16)
|
||||
Line::from(""),
|
||||
// SESSION section (lines 17-22)
|
||||
render_section_header("SESSION", &theme),
|
||||
render_divider(content_width, &theme),
|
||||
render_readonly_line("Tempo", &tempo_str, tempo_style, &theme),
|
||||
render_readonly_line("Beat", &beat_str, value_style, &theme),
|
||||
render_readonly_line("Phase", &phase_str, value_style, &theme),
|
||||
Line::from(""),
|
||||
render_section_header("MIDI OUTPUTS", &theme),
|
||||
render_divider(content_width, &theme),
|
||||
render_option_line("Output 0", &midi_out_0, focus == OptionsFocus::MidiOutput0, &theme),
|
||||
render_option_line("Output 1", &midi_out_1, focus == OptionsFocus::MidiOutput1, &theme),
|
||||
render_option_line("Output 2", &midi_out_2, focus == OptionsFocus::MidiOutput2, &theme),
|
||||
render_option_line("Output 3", &midi_out_3, focus == OptionsFocus::MidiOutput3, &theme),
|
||||
Line::from(""),
|
||||
render_section_header("MIDI INPUTS", &theme),
|
||||
render_divider(content_width, &theme),
|
||||
render_option_line("Input 0", &midi_in_0, focus == OptionsFocus::MidiInput0, &theme),
|
||||
render_option_line("Input 1", &midi_in_1, focus == OptionsFocus::MidiInput1, &theme),
|
||||
render_option_line("Input 2", &midi_in_2, focus == OptionsFocus::MidiInput2, &theme),
|
||||
render_option_line("Input 3", &midi_in_3, focus == OptionsFocus::MidiInput3, &theme),
|
||||
];
|
||||
|
||||
let total_lines = lines.len();
|
||||
let max_visible = padded.height as usize;
|
||||
|
||||
// Map focus to line index
|
||||
let focus_line: usize = match focus {
|
||||
OptionsFocus::ColorScheme => 2,
|
||||
OptionsFocus::RefreshRate => 3,
|
||||
@@ -166,9 +210,16 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
OptionsFocus::LinkEnabled => 12,
|
||||
OptionsFocus::StartStopSync => 13,
|
||||
OptionsFocus::Quantum => 14,
|
||||
OptionsFocus::MidiOutput0 => 25,
|
||||
OptionsFocus::MidiOutput1 => 26,
|
||||
OptionsFocus::MidiOutput2 => 27,
|
||||
OptionsFocus::MidiOutput3 => 28,
|
||||
OptionsFocus::MidiInput0 => 32,
|
||||
OptionsFocus::MidiInput1 => 33,
|
||||
OptionsFocus::MidiInput2 => 34,
|
||||
OptionsFocus::MidiInput3 => 35,
|
||||
};
|
||||
|
||||
// Calculate scroll offset to keep focused line visible (centered when possible)
|
||||
let scroll_offset = if total_lines <= max_visible {
|
||||
0
|
||||
} else {
|
||||
@@ -177,7 +228,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
.min(total_lines.saturating_sub(max_visible))
|
||||
};
|
||||
|
||||
// Render visible portion
|
||||
let visible_end = (scroll_offset + max_visible).min(total_lines);
|
||||
let visible_lines: Vec<Line> = lines
|
||||
.into_iter()
|
||||
@@ -187,7 +237,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
|
||||
frame.render_widget(Paragraph::new(visible_lines), padded);
|
||||
|
||||
// Render scroll indicators
|
||||
let indicator_style = Style::new().fg(theme.ui.text_dim);
|
||||
let indicator_x = padded.x + padded.width.saturating_sub(1);
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table};
|
||||
use ratatui::Frame;
|
||||
|
||||
use cagire_forth::Forth;
|
||||
use crate::app::App;
|
||||
use crate::engine::{LinkState, SequencerSnapshot};
|
||||
use crate::model::{SourceSpan, StepContext, Value};
|
||||
@@ -23,12 +22,17 @@ use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime}
|
||||
use crate::widgets::{
|
||||
ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal,
|
||||
};
|
||||
use cagire_forth::Forth;
|
||||
|
||||
use super::{
|
||||
dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view,
|
||||
};
|
||||
|
||||
fn compute_stack_display(lines: &[String], editor: &cagire_ratatui::Editor, cache: &std::cell::RefCell<Option<StackCache>>) -> String {
|
||||
fn compute_stack_display(
|
||||
lines: &[String],
|
||||
editor: &cagire_ratatui::Editor,
|
||||
cache: &std::cell::RefCell<Option<StackCache>>,
|
||||
) -> String {
|
||||
let cursor_line = editor.cursor().0;
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
@@ -46,7 +50,11 @@ fn compute_stack_display(lines: &[String], editor: &cagire_ratatui::Editor, cach
|
||||
}
|
||||
}
|
||||
|
||||
let partial: Vec<&str> = lines.iter().take(cursor_line + 1).map(|s| s.as_str()).collect();
|
||||
let partial: Vec<&str> = lines
|
||||
.iter()
|
||||
.take(cursor_line + 1)
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
let script = partial.join("\n");
|
||||
|
||||
let result = if script.trim().is_empty() {
|
||||
@@ -70,6 +78,13 @@ fn compute_stack_display(lines: &[String], editor: &cagire_ratatui::Editor, cach
|
||||
speed: 1.0,
|
||||
fill: false,
|
||||
nudge_secs: 0.0,
|
||||
cc_access: None,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_x: 0.5,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_y: 0.5,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_down: 0.0,
|
||||
};
|
||||
|
||||
match forth.evaluate(&script, &ctx) {
|
||||
@@ -233,7 +248,11 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc
|
||||
}
|
||||
|
||||
fn header_height(width: u16) -> u16 {
|
||||
if width >= 80 { 1 } else { 2 }
|
||||
if width >= 80 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
fn render_side_panel(frame: &mut Frame, app: &App, area: Rect) {
|
||||
@@ -277,11 +296,8 @@ fn render_header(
|
||||
.areas(area);
|
||||
(t, l, tp, b, p, s)
|
||||
} else {
|
||||
let [line1, line2] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(area);
|
||||
let [line1, line2] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
|
||||
|
||||
let [t, l, tp, s] = Layout::horizontal([
|
||||
Constraint::Min(12),
|
||||
@@ -291,11 +307,8 @@ fn render_header(
|
||||
])
|
||||
.areas(line1);
|
||||
|
||||
let [b, p] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Fill(2),
|
||||
])
|
||||
.areas(line2);
|
||||
let [b, p] =
|
||||
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(2)]).areas(line2);
|
||||
|
||||
(t, l, tp, b, p, s)
|
||||
};
|
||||
@@ -316,7 +329,11 @@ fn render_header(
|
||||
|
||||
// Fill indicator
|
||||
let fill = app.live_keys.fill();
|
||||
let fill_fg = if fill { theme.status.fill_on } else { theme.status.fill_off };
|
||||
let fill_fg = if fill {
|
||||
theme.status.fill_on
|
||||
} else {
|
||||
theme.status.fill_off
|
||||
};
|
||||
let fill_style = Style::new().bg(theme.status.fill_bg).fg(fill_fg);
|
||||
frame.render_widget(
|
||||
Paragraph::new(if fill { "F" } else { "·" })
|
||||
@@ -343,7 +360,9 @@ fn render_header(
|
||||
.as_deref()
|
||||
.map(|n| format!(" {n} "))
|
||||
.unwrap_or_else(|| format!(" Bank {:02} ", app.editor_ctx.bank + 1));
|
||||
let bank_style = Style::new().bg(theme.header.bank_bg).fg(theme.ui.text_primary);
|
||||
let bank_style = Style::new()
|
||||
.bg(theme.header.bank_bg)
|
||||
.fg(theme.ui.text_primary);
|
||||
frame.render_widget(
|
||||
Paragraph::new(bank_name)
|
||||
.style(bank_style)
|
||||
@@ -374,7 +393,9 @@ fn render_header(
|
||||
" {} · {} steps{}{}{} ",
|
||||
pattern_name, pattern.length, speed_info, page_info, iter_info
|
||||
);
|
||||
let pattern_style = Style::new().bg(theme.header.pattern_bg).fg(theme.ui.text_primary);
|
||||
let pattern_style = Style::new()
|
||||
.bg(theme.header.pattern_bg)
|
||||
.fg(theme.ui.text_primary);
|
||||
frame.render_widget(
|
||||
Paragraph::new(pattern_text)
|
||||
.style(pattern_style)
|
||||
@@ -387,7 +408,9 @@ fn render_header(
|
||||
let peers = link.peers();
|
||||
let voices = app.metrics.active_voices;
|
||||
let stats_text = format!(" CPU {cpu_pct:.0}% V:{voices} L:{peers} ");
|
||||
let stats_style = Style::new().bg(theme.header.stats_bg).fg(theme.header.stats_fg);
|
||||
let stats_style = Style::new()
|
||||
.bg(theme.header.stats_bg)
|
||||
.fg(theme.header.stats_fg);
|
||||
frame.render_widget(
|
||||
Paragraph::new(stats_text)
|
||||
.style(stats_style)
|
||||
@@ -521,11 +544,12 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected)
|
||||
.render_centered(frame, term);
|
||||
}
|
||||
Modal::ConfirmDeleteSteps { steps, selected, .. } => {
|
||||
Modal::ConfirmDeleteSteps {
|
||||
steps, selected, ..
|
||||
} => {
|
||||
let nums: Vec<String> = steps.iter().map(|s| format!("{:02}", s + 1)).collect();
|
||||
let label = format!("Delete steps {}?", nums.join(", "));
|
||||
ConfirmModal::new("Confirm", &label, *selected)
|
||||
.render_centered(frame, term);
|
||||
ConfirmModal::new("Confirm", &label, *selected).render_centered(frame, term);
|
||||
}
|
||||
Modal::ConfirmResetPattern {
|
||||
pattern, selected, ..
|
||||
@@ -630,7 +654,9 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
let step_name = step.and_then(|s| s.name.as_ref());
|
||||
|
||||
let title = match (source_idx, step_name) {
|
||||
(Some(src), Some(name)) => format!("Step {:02}: {} → {:02}", step_idx + 1, name, src + 1),
|
||||
(Some(src), Some(name)) => {
|
||||
format!("Step {:02}: {} → {:02}", step_idx + 1, name, src + 1)
|
||||
}
|
||||
(None, Some(name)) => format!("Step {:02}: {}", step_idx + 1, name),
|
||||
(Some(src), None) => format!("Step {:02} → {:02}", step_idx + 1, src + 1),
|
||||
(None, None) => format!("Step {:02}", step_idx + 1),
|
||||
@@ -809,7 +835,11 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area);
|
||||
} else if app.editor_ctx.show_stack {
|
||||
let stack_text = compute_stack_display(text_lines, &app.editor_ctx.editor, &app.editor_ctx.stack_cache);
|
||||
let stack_text = compute_stack_display(
|
||||
text_lines,
|
||||
&app.editor_ctx.editor,
|
||||
&app.editor_ctx.stack_cache,
|
||||
);
|
||||
let hint = Line::from(vec![
|
||||
Span::styled("Esc", key),
|
||||
Span::styled(" save ", dim),
|
||||
@@ -903,7 +933,9 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
Style::default()
|
||||
.fg(theme.hint.key)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(theme.ui.text_primary).bg(theme.ui.surface),
|
||||
Style::default()
|
||||
.fg(theme.ui.text_primary)
|
||||
.bg(theme.ui.surface),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
@@ -955,7 +987,11 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
.skip(*scroll)
|
||||
.take(visible_rows)
|
||||
.map(|(i, (key, name, desc))| {
|
||||
let bg = if i % 2 == 0 { theme.table.row_even } else { theme.table.row_odd };
|
||||
let bg = if i % 2 == 0 {
|
||||
theme.table.row_even
|
||||
} else {
|
||||
theme.table.row_odd
|
||||
};
|
||||
Row::new(vec![
|
||||
Cell::from(*key).style(Style::default().fg(theme.modal.confirm)),
|
||||
Cell::from(*name).style(Style::default().fg(theme.modal.input)),
|
||||
@@ -997,7 +1033,10 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
Span::styled("Esc/?", Style::default().fg(theme.hint.key)),
|
||||
Span::styled(" close", Style::default().fg(theme.hint.text)),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(keybind_hint).alignment(Alignment::Right), hint_area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(keybind_hint).alignment(Alignment::Right),
|
||||
hint_area,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,12 +23,18 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) {
|
||||
.centered()
|
||||
.build();
|
||||
|
||||
let version_style = Style::new().fg(theme.title.subtitle);
|
||||
|
||||
let subtitle_lines = vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
"A Forth Music Sequencer",
|
||||
Style::new().fg(theme.title.subtitle),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!("v{}", env!("CARGO_PKG_VERSION")),
|
||||
version_style,
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled("by BuboBubo", author_style)),
|
||||
Line::from(""),
|
||||
|
||||
@@ -51,3 +51,9 @@ mod ramps;
|
||||
|
||||
#[path = "forth/generator.rs"]
|
||||
mod generator;
|
||||
|
||||
#[path = "forth/midi.rs"]
|
||||
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]));
|
||||
}
|
||||
@@ -18,6 +18,7 @@ pub fn default_ctx() -> StepContext {
|
||||
speed: 1.0,
|
||||
fill: false,
|
||||
nudge_secs: 0.0,
|
||||
cc_access: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,22 +74,6 @@ fn dupn_alias() {
|
||||
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]
|
||||
fn cycle_zero_count_error() {
|
||||
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() {
|
||||
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");
|
||||
}
|
||||
|
||||
288
tests/forth/midi.rs
Normal file
288
tests/forth/midi.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
use crate::harness::{default_ctx, expect_outputs, forth};
|
||||
use cagire::forth::{CcAccess, StepContext};
|
||||
use cagire::midi::CcMemory;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use cagire::forth::Value;
|
||||
|
||||
#[test]
|
||||
fn test_midi_channel_set() {
|
||||
let outputs = expect_outputs("60 note 100 velocity 3 chan m.", 1);
|
||||
assert!(outputs[0].starts_with("/midi/note/60/vel/100/chan/2/dur/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_note_default_channel() {
|
||||
let outputs = expect_outputs("72 note 80 velocity m.", 1);
|
||||
assert!(outputs[0].starts_with("/midi/note/72/vel/80/chan/0/dur/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_cc() {
|
||||
let outputs = expect_outputs("64 ccnum 127 ccout m.", 1);
|
||||
assert!(outputs[0].contains("/midi/cc/64/127/chan/0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_cc_with_channel() {
|
||||
let outputs = expect_outputs("5 chan 1 ccnum 64 ccout m.", 1);
|
||||
assert!(outputs[0].contains("/midi/cc/1/64/chan/4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ccval_returns_zero_without_cc_memory() {
|
||||
let f = forth();
|
||||
let ctx = default_ctx();
|
||||
let outputs = f.evaluate("1 1 ccval", &ctx).unwrap();
|
||||
assert!(outputs.is_empty());
|
||||
let stack = f.stack();
|
||||
assert_eq!(stack.len(), 1);
|
||||
match &stack[0] {
|
||||
cagire::forth::Value::Int(v, _) => assert_eq!(*v, 0),
|
||||
_ => panic!("expected Int"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ccval_reads_from_cc_memory() {
|
||||
let cc_memory = CcMemory::new();
|
||||
cc_memory.set_cc(0, 0, 1, 64); // device 0, channel 1 (0-indexed), CC 1, value 64
|
||||
cc_memory.set_cc(0, 5, 74, 127); // device 0, channel 6 (0-indexed), CC 74, value 127
|
||||
|
||||
let f = forth();
|
||||
let ctx = StepContext {
|
||||
cc_access: Some(Arc::new(cc_memory.clone()) as Arc<dyn CcAccess>),
|
||||
..default_ctx()
|
||||
};
|
||||
|
||||
// Test CC 1 on channel 1 (user provides 1, internally 0)
|
||||
f.evaluate("1 1 ccval", &ctx).unwrap();
|
||||
let stack = f.stack();
|
||||
assert_eq!(stack.len(), 1);
|
||||
match &stack[0] {
|
||||
cagire::forth::Value::Int(v, _) => assert_eq!(*v, 64),
|
||||
_ => panic!("expected Int"),
|
||||
}
|
||||
f.clear_stack();
|
||||
|
||||
// Test CC 74 on channel 6 (user provides 6, internally 5)
|
||||
f.evaluate("74 6 ccval", &ctx).unwrap();
|
||||
let stack = f.stack();
|
||||
assert_eq!(stack.len(), 1);
|
||||
match &stack[0] {
|
||||
cagire::forth::Value::Int(v, _) => assert_eq!(*v, 127),
|
||||
_ => panic!("expected Int"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_channel_clamping() {
|
||||
// Channel should be clamped 1-16, then converted to 0-15 internally
|
||||
let outputs = expect_outputs("60 note 100 velocity 0 chan m.", 1);
|
||||
assert!(outputs[0].contains("/chan/0")); // 0 clamped to 1, then -1 = 0
|
||||
|
||||
let outputs = expect_outputs("60 note 100 velocity 17 chan m.", 1);
|
||||
assert!(outputs[0].contains("/chan/15")); // 17 clamped to 16, then -1 = 15
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_note_clamping() {
|
||||
let outputs = expect_outputs("-1 note 100 velocity m.", 1);
|
||||
assert!(outputs[0].contains("/note/0"));
|
||||
|
||||
let outputs = expect_outputs("200 note 100 velocity m.", 1);
|
||||
assert!(outputs[0].contains("/note/127"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_velocity_clamping() {
|
||||
let outputs = expect_outputs("60 note -10 velocity m.", 1);
|
||||
assert!(outputs[0].contains("/vel/0"));
|
||||
|
||||
let outputs = expect_outputs("60 note 200 velocity m.", 1);
|
||||
assert!(outputs[0].contains("/vel/127"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_defaults() {
|
||||
// With only note specified, velocity defaults to 100 and channel to 0
|
||||
let outputs = expect_outputs("60 note m.", 1);
|
||||
assert!(outputs[0].starts_with("/midi/note/60/vel/100/chan/0/dur/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_full_defaults() {
|
||||
// With nothing specified, defaults to note=60, velocity=100, channel=0
|
||||
let outputs = expect_outputs("m.", 1);
|
||||
assert!(outputs[0].starts_with("/midi/note/60/vel/100/chan/0/dur/"));
|
||||
}
|
||||
|
||||
// Pitch bend tests
|
||||
#[test]
|
||||
fn test_midi_bend_center() {
|
||||
let outputs = expect_outputs("0.0 bend m.", 1);
|
||||
// 0.0 -> 8192 (center)
|
||||
assert!(
|
||||
outputs[0].contains("/midi/bend/8191/chan/0")
|
||||
|| outputs[0].contains("/midi/bend/8192/chan/0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_bend_max() {
|
||||
let outputs = expect_outputs("1.0 bend m.", 1);
|
||||
// 1.0 -> 16383 (max)
|
||||
assert!(outputs[0].contains("/midi/bend/16383/chan/0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_bend_min() {
|
||||
let outputs = expect_outputs("-1.0 bend m.", 1);
|
||||
// -1.0 -> 0 (min)
|
||||
assert!(outputs[0].contains("/midi/bend/0/chan/0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_bend_with_channel() {
|
||||
let outputs = expect_outputs("0.5 bend 3 chan m.", 1);
|
||||
assert!(outputs[0].contains("/chan/2")); // channel 3 -> 2 (0-indexed)
|
||||
assert!(outputs[0].contains("/midi/bend/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_bend_clamping() {
|
||||
let outputs = expect_outputs("2.0 bend m.", 1);
|
||||
// 2.0 clamped to 1.0 -> 16383
|
||||
assert!(outputs[0].contains("/midi/bend/16383/chan/0"));
|
||||
|
||||
let outputs = expect_outputs("-5.0 bend m.", 1);
|
||||
// -5.0 clamped to -1.0 -> 0
|
||||
assert!(outputs[0].contains("/midi/bend/0/chan/0"));
|
||||
}
|
||||
|
||||
// Channel pressure (aftertouch) tests
|
||||
#[test]
|
||||
fn test_midi_pressure() {
|
||||
let outputs = expect_outputs("64 pressure m.", 1);
|
||||
assert!(outputs[0].contains("/midi/pressure/64/chan/0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_pressure_with_channel() {
|
||||
let outputs = expect_outputs("100 pressure 5 chan m.", 1);
|
||||
assert!(outputs[0].contains("/midi/pressure/100/chan/4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_pressure_clamping() {
|
||||
let outputs = expect_outputs("-10 pressure m.", 1);
|
||||
assert!(outputs[0].contains("/midi/pressure/0/chan/0"));
|
||||
|
||||
let outputs = expect_outputs("200 pressure m.", 1);
|
||||
assert!(outputs[0].contains("/midi/pressure/127/chan/0"));
|
||||
}
|
||||
|
||||
// Program change tests
|
||||
#[test]
|
||||
fn test_midi_program() {
|
||||
let outputs = expect_outputs("0 program m.", 1);
|
||||
assert!(outputs[0].contains("/midi/program/0/chan/0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_program_with_channel() {
|
||||
let outputs = expect_outputs("42 program 10 chan m.", 1);
|
||||
assert!(outputs[0].contains("/midi/program/42/chan/9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_program_clamping() {
|
||||
let outputs = expect_outputs("-1 program m.", 1);
|
||||
assert!(outputs[0].contains("/midi/program/0/chan/0"));
|
||||
|
||||
let outputs = expect_outputs("200 program m.", 1);
|
||||
assert!(outputs[0].contains("/midi/program/127/chan/0"));
|
||||
}
|
||||
|
||||
// MIDI real-time messages
|
||||
#[test]
|
||||
fn test_midi_clock() {
|
||||
let outputs = expect_outputs("mclock", 1);
|
||||
assert_eq!(outputs[0], "/midi/clock/dev/0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_start() {
|
||||
let outputs = expect_outputs("mstart", 1);
|
||||
assert_eq!(outputs[0], "/midi/start/dev/0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_stop() {
|
||||
let outputs = expect_outputs("mstop", 1);
|
||||
assert_eq!(outputs[0], "/midi/stop/dev/0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_continue() {
|
||||
let outputs = expect_outputs("mcont", 1);
|
||||
assert_eq!(outputs[0], "/midi/continue/dev/0");
|
||||
}
|
||||
|
||||
// Test message type priority (first matching type wins)
|
||||
#[test]
|
||||
fn test_midi_message_priority_cc_over_note() {
|
||||
// CC params take priority over note
|
||||
let outputs = expect_outputs("60 note 1 ccnum 64 ccout m.", 1);
|
||||
assert!(outputs[0].contains("/midi/cc/1/64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_message_priority_bend_over_note() {
|
||||
// bend takes priority over note (but not over CC)
|
||||
let outputs = expect_outputs("60 note 0.5 bend m.", 1);
|
||||
assert!(outputs[0].contains("/midi/bend/"));
|
||||
}
|
||||
|
||||
// MIDI note duration tests
|
||||
#[test]
|
||||
fn test_midi_note_default_duration() {
|
||||
// Default dur=1.0, with tempo=120 and speed=1.0, step_duration = 60/120/4/1 = 0.125
|
||||
let outputs = expect_outputs("60 note m.", 1);
|
||||
assert!(outputs[0].contains("/dur/0.125"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_note_explicit_duration() {
|
||||
// dur=0.5 means half step duration = 0.0625 seconds
|
||||
let outputs = expect_outputs("60 note 0.5 dur m.", 1);
|
||||
assert!(outputs[0].contains("/dur/0.0625"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_note_long_duration() {
|
||||
// dur=2.0 means two step durations = 0.25 seconds
|
||||
let outputs = expect_outputs("60 note 2 dur m.", 1);
|
||||
assert!(outputs[0].contains("/dur/0.25"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_note_duration_with_tempo() {
|
||||
use crate::harness::{ctx_with, forth};
|
||||
let f = forth();
|
||||
// At tempo=60, step_duration = 60/60/4/1 = 0.25 seconds
|
||||
let ctx = ctx_with(|c| c.tempo = 60.0);
|
||||
let outputs = f.evaluate("60 note m.", &ctx).unwrap();
|
||||
assert!(outputs[0].contains("/dur/0.25"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_note_duration_with_speed() {
|
||||
use crate::harness::{ctx_with, forth};
|
||||
let f = forth();
|
||||
// At tempo=120 speed=2, step_duration = 60/120/4/2 = 0.0625 seconds
|
||||
let ctx = ctx_with(|c| c.speed = 2.0);
|
||||
let outputs = f.evaluate("60 note m.", &ctx).unwrap();
|
||||
assert!(outputs[0].contains("/dur/0.0625"));
|
||||
}
|
||||
@@ -105,3 +105,74 @@ fn ftom_880() {
|
||||
fn mtof_ftom_roundtrip() {
|
||||
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("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);
|
||||
}
|
||||
|
||||
@@ -42,13 +42,6 @@ fn get_sounds(outputs: &[String]) -> Vec<String> {
|
||||
.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;
|
||||
|
||||
fn approx_eq(a: f64, b: f64) -> bool {
|
||||
@@ -145,25 +138,6 @@ fn cycle_with_sounds() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_n_basic() {
|
||||
let outputs = expect_outputs(r#""kick" s 4 .!"#, 4);
|
||||
let sounds = get_sounds(&outputs);
|
||||
assert_eq!(sounds, vec!["kick", "kick", "kick", "kick"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_n_zero() {
|
||||
let outputs = expect_outputs(r#""kick" s 0 .!"#, 0);
|
||||
assert!(outputs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_n_negative_error() {
|
||||
let f = forth();
|
||||
let result = f.evaluate(r#""kick" s -1 .!"#, &default_ctx());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_single_delta() {
|
||||
@@ -175,7 +149,7 @@ fn at_single_delta() {
|
||||
|
||||
#[test]
|
||||
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 step_dur = 0.125;
|
||||
assert!(approx_eq(deltas[0], 0.0), "expected delta 0, got {}", deltas[0]);
|
||||
@@ -184,7 +158,7 @@ fn at_list_deltas() {
|
||||
|
||||
#[test]
|
||||
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 step_dur = 0.125;
|
||||
assert!(approx_eq(deltas[0], 0.0), "expected delta 0");
|
||||
@@ -194,70 +168,26 @@ fn at_three_deltas() {
|
||||
|
||||
#[test]
|
||||
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);
|
||||
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]
|
||||
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);
|
||||
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]
|
||||
fn at_records_selected_spans() {
|
||||
use cagire::forth::ExecutionTrace;
|
||||
|
||||
let f = forth();
|
||||
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();
|
||||
|
||||
// Should have 6 selected spans: 3 for at deltas + 3 for sound (one per emit)
|
||||
|
||||
3
website/.gitignore
vendored
Normal file
3
website/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
5
website/astro.config.mjs
Normal file
5
website/astro.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
|
||||
export default defineConfig({
|
||||
output: "static",
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 152 KiB |
@@ -1,163 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cagire</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
background: #0a0a0a;
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 70ch;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
border-bottom: 1px solid #333;
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.ascii {
|
||||
color: #64a0b4;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.1;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
nav {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: #cccc44;
|
||||
text-decoration: none;
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #333;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #333;
|
||||
color: #e0e0e0;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: #64a0b4;
|
||||
color: #64a0b4;
|
||||
}
|
||||
|
||||
.screenshot img {
|
||||
width: 100%;
|
||||
border: 1px solid #333;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.screenshot p {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #888;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
section a {
|
||||
color: #cccc44;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
section a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<pre class="ascii">
|
||||
██████╗ █████╗ ██████╗ ██╗██████╗ ███████╗
|
||||
██╔════╝██╔══██╗██╔════╝ ██║██╔══██╗██╔════╝
|
||||
██║ ███████║██║ ███╗██║██████╔╝█████╗
|
||||
██║ ██╔══██║██║ ██║██║██╔══██╗██╔══╝
|
||||
╚██████╗██║ ██║╚██████╔╝██║██║ ██║███████╗
|
||||
╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═╝╚══════╝
|
||||
</pre>
|
||||
<nav>
|
||||
<a href="#description">Description</a>
|
||||
<a href="#releases">Releases</a>
|
||||
<a href="#credits">Credits</a>
|
||||
<a href="#support">Support</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<section id="description" class="screenshot">
|
||||
<img src="cagire.png" alt="Cagire screenshot">
|
||||
<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 creates events. Synchronize with other musicians using Ableton Link. Cagire uses its own audio engine for audio synthesis and sampling!</p>
|
||||
</section>
|
||||
|
||||
<section id="releases">
|
||||
<h2>Releases</h2>
|
||||
<div class="buttons">
|
||||
<a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-macos-aarch64" class="btn">macOS (ARM)</a>
|
||||
<a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-macos-x86_64" class="btn">macOS (Intel)</a>
|
||||
<a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-windows-x86_64.exe" class="btn">Windows</a>
|
||||
<a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-linux-x86_64" class="btn">Linux</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="credits">
|
||||
<h2>Credits</h2>
|
||||
<p>Cagire is built by BuboBubo (Raphael Maurice Forment).</p>
|
||||
<p>Doux (audio engine) is a Rust port of Dough, originally written in C by Felix Roos.</p>
|
||||
<p>mi-plaits-dsp-rs by Oliver Rockstedt, based on Mutable Instruments Plaits by Emilie Gillet.</p>
|
||||
</section>
|
||||
|
||||
<section id="support">
|
||||
<h2>Support</h2>
|
||||
<p>Report issues and contribute on <a href="https://github.com/Bubobubobubobubo/cagire">GitHub</a>.</p>
|
||||
<p>Support the project on <a href="https://ko-fi.com/raphaelbubo">Ko-fi</a>.</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
12
website/package.json
Normal file
12
website/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "cagire-website",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.2.5"
|
||||
}
|
||||
}
|
||||
3142
website/pnpm-lock.yaml
generated
Normal file
3142
website/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user