25 Commits

Author SHA1 Message Date
985ab687d7 chore: Release
Some checks failed
Deploy Website / deploy (push) Failing after 4m46s
CI / build (cagire-linux-x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 12m15s
CI / build (cagire-macos-aarch64, macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / build (cagire-macos-x86_64, macos-15-intel, x86_64-apple-darwin) (push) Has been cancelled
CI / build (cagire-windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
CI / release (push) Has been cancelled
2026-02-02 01:09:13 +01:00
9b925d881e Feat: update changelog
Some checks failed
Deploy Website / deploy (push) Has been cancelled
2026-02-02 01:08:33 +01:00
71146c7cea Feat: more predictable projet load behavior
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-02 01:01:01 +01:00
6b95f31afd Feat: polyphony + iterator reset
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-02 00:33:46 +01:00
adee8d0d57 Feat: adding some basic music theory
Some checks failed
Deploy Website / deploy (push) Failing after 4m49s
2026-02-01 16:15:09 +01:00
f9c284effd Feat: adding logrand and exprand 2026-02-01 15:16:20 +01:00
57fd51be3e Fix release.toml format
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
CI / build (cagire-linux-x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 12m21s
CI / build (cagire-macos-aarch64, macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / build (cagire-macos-x86_64, macos-15-intel, x86_64-apple-darwin) (push) Has been cancelled
CI / build (cagire-windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
CI / release (push) Has been cancelled
2026-02-01 14:05:55 +01:00
ce70251057 Feat: work on metadata and packaging
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-01 14:00:10 +01:00
b47c789612 Feat: continue refactoring
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-01 13:39:25 +01:00
dd853b8e1b Feat: begin slight refactoring
Some checks failed
Deploy Website / deploy (push) Failing after 4m46s
2026-02-01 12:38:48 +01:00
a0585b0814 MIDI Documentation and optional mouse event support
Some checks failed
Deploy Website / deploy (push) Failing after 4m45s
2026-02-01 00:51:56 +01:00
2100b82dad More robust midi implementation
Some checks failed
Deploy Website / deploy (push) Failing after 4m58s
2026-01-31 23:58:57 +01:00
15a4300db5 better quality midi 2026-01-31 23:23:36 +01:00
fed39c01e8 Lots + MIDI implementation 2026-01-31 23:13:51 +01:00
0a4f1419eb Fix: continue to fix release build and CI 2026-01-31 19:58:21 +01:00
793c83e18c Fix: again CI breaks 2026-01-31 18:04:11 +01:00
20bc0ffcb4 Fixing builds and workflows 2026-01-31 17:52:44 +01:00
8e09fd106e Remove emit_n tests (feature not implemented) 2026-01-31 17:37:00 +01:00
73ca0ff096 Add Windows/Linux desktop bundles to CI 2026-01-31 17:24:41 +01:00
425f1c8627 CI build versions 2026-01-31 16:35:38 +01:00
730332cfb0 Work on documentation
Some checks failed
Deploy Website / deploy (push) Failing after 6s
2026-01-31 15:03:20 +01:00
1d70a83759 Work on documentation 2026-01-31 14:31:44 +01:00
0299012725 Work on documentation 2026-01-31 13:46:43 +01:00
08029ec604 Working on internal documentation
Some checks failed
Deploy Website / deploy (push) Failing after 7s
2026-01-31 02:41:05 +01:00
4f9b1f39f9 Write some amount of documentation 2026-01-31 01:46:18 +01:00
123 changed files with 13542 additions and 4094 deletions

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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

Binary file not shown.

BIN
assets/Cagire.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

View File

@@ -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"

View File

@@ -61,7 +61,13 @@ fn tokenize(input: &str) -> Vec<Token> {
continue;
}
// single ; is a word, create token
tokens.push(Token::Word(";".to_string(), SourceSpan { start: pos, end: pos + 1 }));
tokens.push(Token::Word(
";".to_string(),
SourceSpan {
start: pos,
end: pos + 1,
},
));
continue;
}
@@ -96,15 +102,33 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> {
while i < tokens.len() {
match &tokens[i] {
Token::Int(n, span) => ops.push(Op::PushInt(*n, Some(*span))),
Token::Float(f, span) => ops.push(Op::PushFloat(*f, Some(*span))),
Token::Int(n, span) => {
let key = n.to_string();
if let Some(body) = dict.lock().unwrap().get(&key).cloned() {
ops.extend(body);
} else {
ops.push(Op::PushInt(*n, Some(*span)));
}
}
Token::Float(f, span) => {
let key = f.to_string();
if let Some(body) = dict.lock().unwrap().get(&key).cloned() {
ops.extend(body);
} else {
ops.push(Op::PushFloat(*f, Some(*span)));
}
}
Token::Str(s, span) => ops.push(Op::PushStr(s.clone(), Some(*span))),
Token::Word(w, span) => {
let word = w.as_str();
if word == "{" {
let (quote_ops, consumed, end_span) = compile_quotation(&tokens[i + 1..], dict)?;
let (quote_ops, consumed, end_span) =
compile_quotation(&tokens[i + 1..], dict)?;
i += consumed;
let body_span = SourceSpan { start: span.start, end: end_span.end };
let body_span = SourceSpan {
start: span.start,
end: end_span.end,
};
ops.push(Op::Quotation(quote_ops, Some(body_span)));
} else if word == "}" {
return Err("unexpected }".into());
@@ -115,7 +139,8 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> {
} else if word == ";" {
return Err("unexpected ;".into());
} else if word == "if" {
let (then_ops, else_ops, consumed, then_span, else_span) = compile_if(&tokens[i + 1..], dict)?;
let (then_ops, else_ops, consumed, then_span, else_span) =
compile_if(&tokens[i + 1..], dict)?;
i += consumed;
if else_ops.is_empty() {
ops.push(Op::BranchIfZero(then_ops.len(), then_span, None));
@@ -137,7 +162,10 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> {
Ok(ops)
}
fn compile_quotation(tokens: &[Token], dict: &Dictionary) -> Result<(Vec<Op>, usize, SourceSpan), String> {
fn compile_quotation(
tokens: &[Token],
dict: &Dictionary,
) -> Result<(Vec<Op>, usize, SourceSpan), String> {
let mut depth = 1;
let mut end_idx = None;
@@ -172,13 +200,18 @@ fn token_span(tok: &Token) -> Option<SourceSpan> {
}
}
fn compile_colon_def(tokens: &[Token], dict: &Dictionary) -> Result<(usize, String, Vec<Op>), String> {
fn compile_colon_def(
tokens: &[Token],
dict: &Dictionary,
) -> Result<(usize, String, Vec<Op>), String> {
if tokens.is_empty() {
return Err("expected word name after ':'".into());
}
let name = match &tokens[0] {
Token::Word(w, _) => w.clone(),
_ => return Err("expected word name after ':'".into()),
Token::Int(n, _) => n.to_string(),
Token::Float(f, _) => f.to_string(),
Token::Str(s, _) => s.clone(),
};
let mut semi_pos = None;
for (i, tok) in tokens[1..].iter().enumerate() {
@@ -198,11 +231,26 @@ fn compile_colon_def(tokens: &[Token], dict: &Dictionary) -> Result<(usize, Stri
fn tokens_span(tokens: &[Token]) -> Option<SourceSpan> {
let first = tokens.first().and_then(token_span)?;
let last = tokens.last().and_then(token_span)?;
Some(SourceSpan { start: first.start, end: last.end })
Some(SourceSpan {
start: first.start,
end: last.end,
})
}
#[allow(clippy::type_complexity)]
fn compile_if(tokens: &[Token], dict: &Dictionary) -> Result<(Vec<Op>, Vec<Op>, usize, Option<SourceSpan>, Option<SourceSpan>), String> {
fn compile_if(
tokens: &[Token],
dict: &Dictionary,
) -> Result<
(
Vec<Op>,
Vec<Op>,
usize,
Option<SourceSpan>,
Option<SourceSpan>,
),
String,
> {
let mut depth = 1;
let mut else_pos = None;
let mut then_pos = None;

View File

@@ -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};

View File

@@ -53,10 +53,11 @@ pub enum Op {
Set,
GetContext(String),
Rand,
ExpRand,
LogRand,
Seed,
Cycle,
PCycle,
TCycle,
Choose,
ChanceExec,
ProbExec,
@@ -79,11 +80,19 @@ pub enum Op {
Loop,
Degree(&'static [i64]),
Oct,
EmitN,
ClearCmd,
SetSpeed,
At,
IntRange,
Generate,
GeomRange,
Times,
Chord(&'static [i64]),
// MIDI
MidiEmit,
GetMidiCC,
MidiClock,
MidiStart,
MidiStop,
MidiContinue,
}

View 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)
}

View File

@@ -1,3 +1,4 @@
pub mod chords;
mod scales;
pub use scales::lookup;

View File

@@ -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();
}
}

View File

@@ -91,45 +91,51 @@ impl Forth {
let mut pc = 0;
let trace_cell = std::cell::RefCell::new(trace);
let run_quotation =
|quot: Value, stack: &mut Vec<Value>, outputs: &mut Vec<String>, cmd: &mut CmdRegister| -> Result<(), String> {
match quot {
Value::Quotation(quot_ops, body_span) => {
if let Some(span) = body_span {
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
trace.executed_spans.push(span);
}
let run_quotation = |quot: Value,
stack: &mut Vec<Value>,
outputs: &mut Vec<String>,
cmd: &mut CmdRegister|
-> Result<(), String> {
match quot {
Value::Quotation(quot_ops, body_span) => {
if let Some(span) = body_span {
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
trace.executed_spans.push(span);
}
let mut trace_opt = trace_cell.borrow_mut().take();
self.execute_ops(
&quot_ops,
ctx,
stack,
outputs,
cmd,
trace_opt.as_deref_mut(),
)?;
*trace_cell.borrow_mut() = trace_opt;
Ok(())
}
_ => Err("expected quotation".into()),
}
};
let select_and_run =
|selected: Value, stack: &mut Vec<Value>, outputs: &mut Vec<String>, cmd: &mut CmdRegister| -> Result<(), String> {
if let Some(span) = selected.span() {
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
trace.selected_spans.push(span);
}
}
if matches!(selected, Value::Quotation(..)) {
run_quotation(selected, stack, outputs, cmd)
} else {
stack.push(selected);
let mut trace_opt = trace_cell.borrow_mut().take();
self.execute_ops(
&quot_ops,
ctx,
stack,
outputs,
cmd,
trace_opt.as_deref_mut(),
)?;
*trace_cell.borrow_mut() = trace_opt;
Ok(())
}
};
_ => Err("expected quotation".into()),
}
};
let select_and_run = |selected: Value,
stack: &mut Vec<Value>,
outputs: &mut Vec<String>,
cmd: &mut CmdRegister|
-> Result<(), String> {
if let Some(span) = selected.span() {
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
trace.selected_spans.push(span);
}
}
if matches!(selected, Value::Quotation(..)) {
run_quotation(selected, stack, outputs, cmd)
} else {
stack.push(selected);
Ok(())
}
};
let drain_select_run = |count: usize,
idx: usize,
@@ -146,15 +152,37 @@ impl Forth {
select_and_run(selected, stack, outputs, cmd)
};
let emit_with_cycling = |cmd: &CmdRegister, emit_idx: usize, delta_secs: f64, outputs: &mut Vec<String>| -> Result<Option<Value>, String> {
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,
outputs: &mut Vec<String>|
-> Result<Option<Value>, String> {
let (sound_opt, params) = cmd.snapshot().ok_or("nothing to emit")?;
let resolved_sound_val = sound_opt.map(|sv| resolve_cycling(sv, emit_idx));
let sound_str = match &resolved_sound_val {
Some(v) => Some(v.as_str()?.to_string()),
None => None,
};
let resolved_params: Vec<(String, String)> =
params.iter().map(|(k, v)| {
let resolved_params: Vec<(String, String)> = params
.iter()
.map(|(k, v)| {
let resolved = resolve_cycling(v, emit_idx);
if let Value::CycleList(_) = v {
if let Some(span) = resolved.span() {
@@ -164,8 +192,15 @@ impl Forth {
}
}
(k.clone(), resolved.to_param_string())
}).collect();
emit_output(sound_str.as_deref(), &resolved_params, ctx.step_duration(), delta_secs, outputs);
})
.collect();
emit_output(
sound_str.as_deref(),
&resolved_params,
ctx.step_duration(),
delta_secs,
outputs,
);
Ok(resolved_sound_val.map(|v| v.into_owned()))
};
@@ -345,36 +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);
}
}
}
}
}
}
@@ -407,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);
@@ -417,7 +478,11 @@ impl Forth {
let a = stack.pop().ok_or("stack underflow")?;
match (&a, &b) {
(Value::Int(a_i, _), Value::Int(b_i, _)) => {
let (lo, hi) = if a_i <= b_i { (*a_i, *b_i) } else { (*b_i, *a_i) };
let (lo, hi) = if a_i <= b_i {
(*a_i, *b_i)
} else {
(*b_i, *a_i)
};
let val = self.rng.lock().unwrap().gen_range(lo..=hi);
stack.push(Value::Int(val, None));
}
@@ -425,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);
@@ -447,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 {
@@ -577,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")?;
@@ -629,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);
}
@@ -708,16 +776,6 @@ impl Forth {
cmd.clear();
}
Op::EmitN => {
let n = stack.pop().ok_or("stack underflow")?.as_int()?;
if n < 0 {
return Err("emit count must be >= 0".into());
}
for i in 0..n as usize {
emit_with_cycling(cmd, i, ctx.nudge_secs, outputs)?;
}
}
Op::IntRange => {
let end = stack.pop().ok_or("stack underflow")?.as_int()?;
let start = stack.pop().ok_or("stack underflow")?.as_int()?;
@@ -748,6 +806,21 @@ impl Forth {
}
}
Op::Times => {
let quot = stack.pop().ok_or("stack underflow")?;
let count = stack.pop().ok_or("stack underflow")?.as_int()?;
if count < 0 {
return Err("times count must be >= 0".into());
}
for i in 0..count {
self.vars
.lock()
.unwrap()
.insert("i".to_string(), Value::Int(i, None));
run_quotation(quot.clone(), stack, outputs, cmd)?;
}
}
Op::GeomRange => {
let count = stack.pop().ok_or("stack underflow")?.as_int()?;
let ratio = stack.pop().ok_or("stack underflow")?.as_float()?;
@@ -761,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;
}
@@ -769,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,

View File

@@ -1,3 +1,6 @@
use std::collections::HashMap;
use std::sync::LazyLock;
use super::ops::Op;
use super::theory;
use super::types::{Dictionary, SourceSpan};
@@ -438,16 +441,6 @@ pub const WORDS: &[Word] = &[
compile: Simple,
varargs: false,
},
Word {
name: ".!",
aliases: &[],
category: "Sound",
stack: "(n --)",
desc: "Emit current sound n times",
example: "\"kick\" s 4 .!",
compile: Simple,
varargs: true,
},
// Variables (prefix syntax: @name to fetch, !name to store)
Word {
name: "@<var>",
@@ -473,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",
@@ -493,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",
@@ -523,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",
@@ -533,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",
@@ -543,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: &[],
@@ -763,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",
@@ -784,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",
@@ -899,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,
},
@@ -1160,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",
@@ -1170,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",
@@ -1180,7 +1502,7 @@ pub const WORDS: &[Word] = &[
Word {
name: "velocity",
aliases: &[],
category: "Gain",
category: "Envelope",
stack: "(f --)",
desc: "Set velocity",
example: "100 velocity",
@@ -1190,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",
@@ -1470,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",
@@ -1480,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",
@@ -1490,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",
@@ -1500,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",
@@ -1510,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",
@@ -1520,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",
@@ -1540,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",
@@ -1550,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",
@@ -1560,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",
@@ -1570,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",
@@ -1580,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",
@@ -1620,7 +1942,7 @@ pub const WORDS: &[Word] = &[
Word {
name: "fm",
aliases: &[],
category: "Modulation",
category: "FM",
stack: "(f --)",
desc: "Set FM frequency",
example: "200 fm",
@@ -1630,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",
@@ -1640,7 +1962,7 @@ pub const WORDS: &[Word] = &[
Word {
name: "fmshape",
aliases: &[],
category: "Modulation",
category: "FM",
stack: "(f --)",
desc: "Set FM shape",
example: "0 fmshape",
@@ -1650,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",
@@ -1660,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",
@@ -1670,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",
@@ -1680,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",
@@ -1690,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",
@@ -1860,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",
@@ -1870,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",
@@ -1880,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",
@@ -1890,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 +2602,164 @@ pub const WORDS: &[Word] = &[
compile: Simple,
varargs: false,
},
Word {
name: "times",
aliases: &[],
category: "Control",
stack: "(n quot --)",
desc: "Execute quotation n times, @i holds current index",
example: "4 { @i . } times => 0 1 2 3",
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,
@@ -2352,11 +2831,17 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"chain" => Op::Chain,
"loop" => Op::Loop,
"oct" => Op::Oct,
".!" => Op::EmitN,
"clear" => Op::ClearCmd,
".." => Op::IntRange,
"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

View 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"

View 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())]
}
}

View 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};

View 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);
}
}

View 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
}
}

View File

@@ -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"] }

View File

@@ -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

View File

@@ -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(),
}
}
}

View File

@@ -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"

View File

@@ -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)))
})

View File

@@ -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;

View File

@@ -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(),
}
};

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View 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,
},
}
}

View 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,
},
}
}

View 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,
},
}
}

View 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,
},
}
}

View 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,
},
}
}

View 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,
}

View 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,
},
}
}

View 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,
},
}
}

View 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,
},
}
}

View 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,
},
}
}

View 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,
},
}
}

View 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,
},
}
}

View 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,
},
}
}

View File

@@ -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;

View File

@@ -1,18 +0,0 @@
# About
Cagire is an experimental step sequencer built by BuboBubo (Raphaël Maurice Forment). It is a free and open-source project licensed under the AGPL-3.0 License. Cagire has been developed as a side project. I wanted to learn more about using Forth and needed a playground for experimentating with this audio engine! You are free to contribute to the project by making direct contributions to the codebase or by providing feedback and suggestions.
## Credits
- **Doux** (audio engine) is a Rust port of Dough, originally written in C by Felix Roos.
- **mi-plaits-dsp-rs** is a Rust port of the code used by the Mutable Instruments Plaits.
* _Author_: Oliver Rockstedt [info@sourcebox.de](info@sourcebox.de).
* _Original author_: Emilie Gillet [emilie.o.gillet@gmail.com](emilie.o.gillet@gmail.com).
## About live coding
Live coding is a technique where a programmer writes code in real-time in front of an audience. It is a way to experiment with code, to share things and thoughts openly, to express yourself through code. It can be technical, poetical, weird, preferably all at once. Live coding can be used to create music, visual art, and other forms of media. Learn more about live coding on [https://toplap.org](https://toplap.org) or [https://livecoding.fr](https://livecoding.fr). Live coding is an autotelic activity: it is an activity that is intrinsically rewarding, and the act of doing it is its own reward. There are no errors, only fun to be found by playing music.
## About the tool
I do not want to pair it with a DAW, I do not want to make it fit with other commercial software. I'm not interested in VSTs or other proprietary workstations. Please, try to think of Cagire as an alternative to other tools.

115
docs/about_forth.md Normal file
View File

@@ -0,0 +1,115 @@
# About Forth
Forth is a _stack-based_ programming language created by Charles H. Moore in the early 1970s. It was designed with simplicity, directness, and interactive exploration in mind. Forth has been used for many years to do scientific work and program embedded systems: it was used to control telescopes and was running on some devices used in space missions among other things. Forth quickly evolved into multiple implementations targetting various computer architectures. None of them really took off and became popular. Nonetheless, the ideas behind Forth continue to garner the interest of many different people in very different (often unrelated) fields. Nowadays, Forth languages are used by hackers and artists for their peculiarity. Forth is simple, direct and beautiful to implement. Forth is an elegant and minimal language to learn. It is easy to understand, to extend and to apply to a specific task. The Forth we use in Cagire is specialized in making live music. We think of it as a DSL: a _Domain Specific Language_.
## Why Forth?
Most programming languages nowadays use a complex syntax made of `variables`, `expressions` and `statements` like `x = 3 + 4`. Forth works differently. It is way more simple than that, has almost no syntax and performs computations in a quite unique way. You push values onto a `stack` and apply `words` that transform them:
```forth
3 4 +
```
This program leaves the number `7` on the stack. There are no variables, no parentheses, no syntax to remember. You just end up with words and numbers separated by spaces. For live coding music, this directness is quite exciting. All you do is think in terms of transformations and add things to the stack: take a note, shift it up, add reverb, play it.
## The Stack
The stack is where values live. When you type a number, it goes on the stack. When you type a word, it usually takes values off and puts new ones back.
```forth
3 ( stack: 3 )
4 ( stack: 3 4 )
+ ( stack: 7 )
```
The stack is `last-in, first-out`. The most recent value is always on top. This means that its often better to read Forth programs from the end to the beginning: from right to left, from the bottom to the top.
## Words
Everything in Forth is either a `number` or a `word`. Words are like functions but conceptually simpler. They have no arguments or return values in the traditional sense. They just manipulate the stack directly.
```forth
dup ( duplicate the top value )
drop ( discard the top value )
swap ( swap the top two values )
```
Words compose naturally on the stack. To double a number:
```forth
3 dup + ( 3 3 +)
```
There are a lot of words in a Forth and thus, Cagire has a `Dictionary` embedded directly into the application. You can also create your own words. They will work just like the already existing words. There are good reasons to create new words on-the-fly:
- To make synth definitions.
- To abstract _some piece of code_ that you use frequently.
- To share data and processes between different steps.
## Values
Four types of values can live on the stack:
- **Integers**: `42`, `-7`, `0`
- **Floats**: `0.5`, `3.14`, `-1.0`
- **Strings**: `"kick"`, `"hello"`
- **Quotations**: `{ dup + }` (code as data)
Quotations are special. They let you pass code around as a value. This is how conditionals and loops work. Think nothing of it for now, you will learn more about how to use it later on.
## Stack Notation
Documentation uses a notation to show what words do:
```
( before -- after )
```
For example, `+` has the signature `( a b -- sum )`. It takes two values and leaves one.
## The Command Register
Traditional Forth programs print text to a terminal. Cagire's Forth builds sound commands instead. This happens through an invisible accumulator called the command register. The command register has two parts:
- a **sound name** (what instrument to play)
- a list of **parameters** (how to play it)
Three types of words interact with it:
```forth
kick sound ;; sets the sound name
0.5 gain ;; adds a parameter
. ;; emits the command and clears the register
```
The word `sound` (or its shorthand `s`) sets what sound to play. Parameter words like `gain`, `freq`, `decay`, or `verb` add key-value pairs to the register. Nothing happens until you emit with `.` (dot). At that moment, the register is packaged into a command and sent to the audio engine.
This design lets you build sounds incrementally:
```forth
"sine" sound
c4 note
0.5 gain
0.3 decay
0.4 verb
.
```
Each line adds something to the register. The final `.` triggers the sound. You can also write it all on one line:
```forth
"sine" s c4 note 0.5 gain 0.3 decay 0.4 verb .
```
The order of parameters does not matter. You can even emit multiple times in a single step. If you need to discard the register without emitting, use `clear`:
```forth
"kick" s 0.5 gain clear ;; nothing plays, register is emptied
"hat" s . ;; only the hat plays
```
This is useful when conditionals might cancel a sound before it emits.
## More details
- Each step has its own stack and independant runtime.
- Word definitions and variable definitions are shared by all steps.

View File

@@ -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)

51
docs/banks_patterns.md Normal file
View File

@@ -0,0 +1,51 @@
# Banks & Patterns
Cagire organizes all your patterns and data following a strict hierarchy:
- **Projects** contain **Banks**.
- **Banks** contain **Patterns**.
- **Patterns** contain **Steps**.
## Structure
```
Project
└── 32 Banks
└── 32 Patterns (per bank)
└── 128 Steps (per pattern)
```
A single project gives you 32 banks, each holding 32 patterns. You get 1024 patterns in each project, ~131.000 steps.
## Patterns
Each pattern is an independent sequence of steps with its own properties:
| Property | Description | Default |
|----------|-------------|---------|
| Length | Steps before the pattern loops (`1`-`128`) | `16` |
| Speed | Playback rate (`1/8x` to `8x`) | `1x` |
| Quantization | When the pattern launches | `Bar` |
| Sync Mode | Reset or Phase-Lock on re-trigger | `Reset` |
Press `e` in the patterns view to edit these settings.
## Patterns View
Access the patterns view with `Ctrl+Up` from the sequencer. The view shows all banks and patterns in a grid. Indicators show pattern state:
- `>` Currently playing
- `+` Staged to play
- `-` Staged to stop
### Keybindings
| Key | Action |
|-----|--------|
| `Arrows` | Navigate banks and patterns |
| `Enter` | Select and return to sequencer |
| `Space` | Toggle pattern playback |
| `e` | Edit pattern properties |
| `r` | Rename bank or pattern |
| `c` / `v` | Copy / Paste |
| `Delete` | Reset to empty pattern |

95
docs/definitions.md Normal file
View File

@@ -0,0 +1,95 @@
# Creating Words
One of Forth's most powerful features is the ability to define new words. A word definition gives a name to a sequence of operations. Once defined, you can use the new word just like any built-in word.
## The Syntax
Use `:` to start a definition and `;` to end it:
```forth
: double dup + ;
```
This creates a word called `double` that duplicates the top value and adds it to itself. Now you can use it:
```forth
3 double ;; leaves 6 on the stack
5 double ;; leaves 10 on the stack
```
The definition is simple: everything between `:` and `;` becomes the body of the word.
## Definitions Are Shared
When you define a word in one step, it becomes available to all other steps. This is how you share code across your pattern. Define your synths, rhythms, and utilities once, then use them everywhere.
Step 0:
```forth
: bass "saw" s 0.8 gain 800 lpf ;
```
Step 4:
```forth
c2 note bass .
```
Step 8:
```forth
g2 note bass .
```
The `bass` word carries the sound design. Each step just adds a note and plays.
## Redefining Words
You can redefine any word, including built-in ones:
```forth
: dup drop ;
```
Now `dup` does the opposite of what it used to do. This is powerful but dangerous. Redefining core words can break things in subtle ways.
You can even redefine numbers:
```forth
: 2 4 ;
```
Now `2` pushes `4` onto the stack. The number two no longer exists in your session. This is a classic Forth demonstration: nothing is sacred, everything can be redefined.
## Practical Uses
**Synth definitions** save you from repeating sound design:
```forth
: pad "sine" s 0.3 gain 2 attack 0.5 verb ;
```
**Transpositions** and musical helpers:
```forth
: octup 12 + ;
: octdn 12 - ;
```
## Words That Emit
A word can contain `.` to emit sounds directly:
```forth
: kick "kick" s . ;
: hat "hat" s 0.4 gain . ;
```
Then a step becomes trivial:
```forth
kick hat
```
Two sounds, two words, no clutter.
## Stack Effects
When you create a word, think about what it expects on the stack and what it leaves behind. The word `double` expects one number and leaves one number. The word `kick` expects nothing and leaves nothing (it emits a sound as a side effect). Well-designed words have clear stack effects. This makes them easy to combine.

38
docs/dictionary.md Normal file
View File

@@ -0,0 +1,38 @@
# The Dictionary
Cagire includes a built-in dictionary of all the internal Forth words. Press `Ctrl+Up` to reach the **Dict** view.
## Using the Dictionary
The dictionary shows every available word organized by category:
- **Stack**: Manipulation words like `dup`, `swap`, `drop`.
- **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...
This tutorial will not teach you how to use all words. The syntax is very uniform and you can quickly learn a new word when necessary. We encourage you to explore as you play, this is the best way to learn. The tutorial will remain focused on various topics that require you to apply knowledge to a given task or specific context.
## Navigation
| Key | Action |
|-----|--------|
| `Tab` | Switch between categories and words |
| `↑/↓` or `j/k` | Navigate items |
| `PgUp/PgDn` | Page through lists |
| `/` or `Ctrl+F` | Search |
| `Esc` | Clear search |
Each word entry shows:
- **Name** and aliases
- **Stack effect**: `( before -- after )`
- **Description**: What the word does
- **Example**: How to use it
Press `/` to search across all words. The search matches word names, aliases, and descriptions. Press `Esc` to clear and return to browsing.
Use the dictionary while writing scripts to check stack effects and study their behavior. Some words also come with shorter aliases (e.g., `sound``s`). You will learn aliases quite naturally, because aliases are usually reserved for very common words.

62
docs/editing.md Normal file
View File

@@ -0,0 +1,62 @@
# Editing a Step
Each step in Cagire contains a Forth script. When the sequencer reaches that step, it runs the script to produce sound. This is where you write your music. Press `Enter` when hovering over any step to open the `code editor`. The editor appears as a modal overlay with the step number in the title bar. If the step is a linked step (shown with an arrow like `→05`), pressing `Enter` navigates to the source step instead.
## Writing Scripts
Scripts are written in Forth. Type words and numbers separated by spaces. The simplest script that makes sound is:
```forth
sine sound .
```
Add parameters before words to modify them:
```forth
c4 note 0.75 decay sine sound .
```
Writing long lines is not recommended because it can become quite unmanageable. Instead, break them into multiple lines for clarity:
```forth
c4 note
0.75 decay
sine sound
0.4 verb
.
```
## Saving
- `Esc` - Save and close the editor
- `Ctrl+E` - Save without closing
When you save, the script is compiled and sent to the sequencer. If there's an error, a message appears briefly at the bottom of the screen. You will also receive visual feedback in the form of a flashing window when saving / evaluating a script.
## Completion
As you type, the editor suggests matching Forth words. The completion list shows all built-in words that start with your current input. Press `Tab` to insert the selected suggestion, or `Esc` to dismiss the list. Use arrow keys to navigate between suggestions.
Completion helps you discover words without memorizing them all. Type a few letters and browse what's available. For example, typing `ver` will suggest `verb` (reverb), typing `fil` will show filter-related words.
## Debugging
Press `Ctrl+S` to toggle the stack display. This shows the stack state evaluated up to the cursor line, useful for understanding how values flow through your script. Press `Ctrl+R` to execute the script immediately without waiting for the sequencer to reach the step.
## Keybindings
| Key | Action |
|-----|--------|
| `Esc` | Save and close |
| `Ctrl+E` | Save without closing |
| `Ctrl+R` | Execute script once |
| `Ctrl+S` | Toggle stack display |
| `Ctrl+F` | Search |
| `Ctrl+N` | Find next |
| `Ctrl+P` | Find previous |
| `Ctrl+A` | Select all |
| `Ctrl+C` | Copy |
| `Ctrl+X` | Cut |
| `Ctrl+V` | Paste |
| `Shift+Arrows` | Extend selection |
| `Tab` | Accept completion |

65
docs/engine_distortion.md Normal file
View File

@@ -0,0 +1,65 @@
# Distortion
Distortion effects add harmonics by nonlinearly shaping the waveform.
## Saturation
Soft saturation using the transfer function `x / (1 + k|x|)`.
```forth
saw 2 distort .
saw 8 distort 0.5 distortvol . ( with volume compensation )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `distort` | 0+ | Saturation amount |
| `distortvol` | 0-1 | Output volume |
## Wavefolding
Wavefolding reflects the signal when it exceeds ±1, using `sin(x × amount × π/2)`.
```forth
sine 4 fold .
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `fold` | 0+ | Fold amount |
## Wavewrapping
Wavewrapping applies modulo to wrap the signal into the -1 to 1 range.
```forth
saw 3 wrap .
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `wrap` | 0+ | Number of wraps |
## Bit Crushing
Bit crushing quantizes the signal to fewer amplitude levels.
```forth
snare 6 crush . ( 6-bit = 32 levels )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `crush` | 1-16 | Bit depth |
## Sample Rate Reduction
Sample rate reduction holds each sample for multiple output samples.
```forth
hat 4 coarse . ( 1/4 effective sample rate )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `coarse` | 1+ | Reduction factor (1 = bypass) |

131
docs/engine_filters.md Normal file
View File

@@ -0,0 +1,131 @@
# Filters
Filters attenuate frequencies above or below a cutoff point.
## Lowpass Filter
The lowpass filter (`lpf`) attenuates frequencies above the cutoff.
```forth
saw 1000 lpf . ( cut above 1000 Hz )
saw 500 lpf 0.8 lpq . ( with resonance )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `lpf` | Hz | Cutoff frequency |
| `lpq` | 0-1 | Resonance (peak at cutoff) |
## Highpass Filter
The highpass filter (`hpf`) attenuates frequencies below the cutoff.
```forth
kick 200 hpf . ( cut below 200 Hz )
pad 400 hpf 0.3 hpq . ( with resonance )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `hpf` | Hz | Cutoff frequency |
| `hpq` | 0-1 | Resonance |
## Bandpass Filter
The bandpass filter (`bpf`) attenuates frequencies outside a band around the center frequency.
```forth
noise 1000 bpf 0.7 bpq . ( narrow band around 1000 Hz )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `bpf` | Hz | Center frequency |
| `bpq` | 0-1 | Resonance (narrower band) |
## Filter Slope
The `ftype` parameter sets the filter slope (rolloff steepness).
| Value | Slope |
|-------|-------|
| `1` | 12 dB/octave |
| `2` | 24 dB/octave (default) |
| `3` | 48 dB/octave |
```forth
saw 800 lpf 3 ftype . ( 48 dB/oct lowpass )
```
## Filter Envelope
Filters can be modulated by an ADSR envelope. The envelope multiplies the base cutoff:
```
final_cutoff = lpf + (lpe × envelope × lpf)
```
When the envelope is at 1.0 and `lpe` is 1.0, the cutoff doubles. When the envelope is at 0, the cutoff equals `lpf`.
```forth
saw 200 lpf 2 lpe 0.01 lpa 0.3 lpd . ( cutoff sweeps from 600 Hz down to 200 Hz )
```
| Parameter | Description |
|-----------|-------------|
| `lpe` | Envelope depth (multiplier, 1.0 = double cutoff at peak) |
| `lpa` | Attack time in seconds |
| `lpd` | Decay time in seconds |
| `lps` | Sustain level (0-1) |
| `lpr` | Release time in seconds |
The same pattern works for highpass (`hpe`, `hpa`, etc.) and bandpass (`bpe`, `bpa`, etc.).
## Ladder Filters
Ladder filters use a different algorithm (Moog-style) with self-oscillation at high resonance.
```forth
saw 800 llpf 0.7 llpq . ( ladder lowpass )
saw 300 lhpf 0.5 lhpq . ( ladder highpass )
saw 1000 lbpf 0.8 lbpq . ( ladder bandpass )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `llpf` | Hz | Ladder lowpass cutoff |
| `llpq` | 0-1 | Ladder lowpass resonance |
| `lhpf` | Hz | Ladder highpass cutoff |
| `lhpq` | 0-1 | Ladder highpass resonance |
| `lbpf` | Hz | Ladder bandpass cutoff |
| `lbpq` | 0-1 | Ladder bandpass resonance |
Ladder filters share the lowpass envelope parameters (`lpe`, `lpa`, etc.).
## EQ
The 3-band EQ applies shelf and peak filters at fixed frequencies.
```forth
kick 3 eqlo -2 eqhi . ( +3 dB at 200 Hz, -2 dB at 5000 Hz )
snare 2 eqmid . ( +2 dB at 1000 Hz )
```
| Parameter | Frequency | Type |
|-----------|-----------|------|
| `eqlo` | 200 Hz | Low shelf (dB) |
| `eqmid` | 1000 Hz | Peak (dB) |
| `eqhi` | 5000 Hz | High shelf (dB) |
## Tilt EQ
Tilt EQ applies a high shelf at 800 Hz with up to ±6 dB gain.
```forth
pad -0.5 tilt . ( -3 dB above 800 Hz )
hat 0.5 tilt . ( +3 dB above 800 Hz )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `tilt` | -1 to 1 | High shelf gain (-1 = -6 dB, 0 = flat, 1 = +6 dB) |

55
docs/engine_intro.md Normal file
View File

@@ -0,0 +1,55 @@
# Introduction
Cagire includes an audio engine called `Doux`. No external software is needed to make sound. `Doux` is an opinionated, semi-modular synthesizer. It was designed for live coding environments and works by receiving command strings that describe sounds. Despite its fixed architecture,`Doux` is extremely versatile and will likely cover most of the audio needs of a live coder.
## How It Works
When you write a Forth script and emit (`.`), the script produces a command string. This command travels to the audio engine, which interprets it and creates a voice. The voice plays until its envelope finishes or until it is killed by another voice. You can also spawn infinite voices, but you will need to manage their lifecycle manually, otherwise they will never stop.
```forth
saw s c4 note 0.8 gain 0.3 verb .
```
## Voices
Each `emit` (`.`) creates or manages a voice by sending parameters. Voices are independent sound generators with their own oscillator, envelope, and effects. The engine can run many voices at once (up to `128`, default `32`). When you exceed the voice limit, the oldest voice is stolen (a process called _round robin scheduling_). You can monitor voice usage on the Engine page:
- **Active voices**: how many are playing right now.
- **Peak voices**: the maximum reached since last reset.
Press `r` on the Engine page to reset the peak counter.
## Parameters
After selecting a sound source, you add parameters. Each parameter word takes a value from the stack and stores it in the command register:
```forth
saw s
c4 note ;; pitch
0.5 gain ;; volume
0.1 attack ;; envelope attack time
2000 lpf ;; lowpass filter at 2kHz
0.3 verb ;; reverb mix
.
```
Parameters can appear in any order. They accumulate until you emit. You can clear the register using the `clear` word.
## Controlling Existing Voices
You can emit without a sound name. In this case, no new voice is created. Instead, the parameters are sent to control an existing voice. Use `voice` with an ID to target a specific voice:
```forth
0 voice 500 freq . ;; change frequency on voice 0
```
This is useful for modulating long-running or infinite voices. Set up a drone on one step with a known voice ID, then tweak its parameters from other steps.
## Hush and Panic
Two emergency controls exist on the Engine page:
- `h` - **Hush**: gracefully fade out all voices
- `p` - **Panic**: immediately kill all voices
Use hush when things get too loud. Use panic when things go wrong.

132
docs/engine_modulation.md Normal file
View File

@@ -0,0 +1,132 @@
# Modulation
Modulation effects vary parameters over time using LFOs or envelopes.
## Vibrato
Vibrato modulates pitch with an LFO.
```forth
saw 5 vib 0.5 vibmod . ( 5 Hz, 0.5 semitone depth )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `vib` | Hz | LFO rate |
| `vibmod` | semitones | Modulation depth |
| `vibshape` | shape | LFO waveform (sine, tri, saw, square) |
## Pitch Envelope
The pitch envelope applies an ADSR to the oscillator frequency.
```forth
sine 100 freq 24 penv 0.001 patt 0.1 pdec .
```
| Parameter | Description |
|-----------|-------------|
| `penv` | Envelope depth in semitones |
| `patt` | Attack time in seconds |
| `pdec` | Decay time in seconds |
| `psus` | Sustain level (0-1) |
| `prel` | Release time in seconds |
## Glide
Glide interpolates between pitch changes over time.
```forth
saw c4 0.1 glide . ( 100ms glide )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `glide` | seconds | Glide time |
## FM Synthesis
FM modulates the carrier frequency with a modulator oscillator.
```forth
sine 440 freq 2 fm 2 fmh . ( modulator at 2× carrier frequency )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `fm` | 0+ | Modulation index |
| `fmh` | ratio | Harmonic ratio (modulator / carrier) |
| `fmshape` | shape | Modulator waveform |
FM has its own envelope (`fme`, `fma`, `fmd`, `fms`, `fmr`).
## Amplitude Modulation
AM multiplies the signal by an LFO.
```forth
pad 4 am 0.5 amdepth . ( 4 Hz tremolo )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `am` | Hz | LFO rate |
| `amdepth` | 0-1 | Modulation depth |
| `amshape` | shape | LFO waveform |
## Ring Modulation
Ring modulation multiplies two signals, producing sum and difference frequencies.
```forth
saw 150 rm 0.8 rmdepth . ( ring mod at 150 Hz )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `rm` | Hz | Modulator frequency |
| `rmdepth` | 0-1 | Modulation depth |
| `rmshape` | shape | Modulator waveform |
## Phaser
Phaser sweeps notches through the frequency spectrum using allpass filters.
```forth
pad 0.5 phaser 0.6 phaserdepth .
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `phaser` | Hz | Sweep rate |
| `phaserdepth` | 0-1 | Sweep depth |
| `phasersweep` | cents | Sweep range |
| `phasercenter` | Hz | Center frequency |
## Flanger
Flanger mixes the signal with a short modulated delay (0.5-10ms).
```forth
pad 0.3 flanger 0.7 flangerdepth 0.5 flangerfeedback .
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `flanger` | Hz | Modulation rate |
| `flangerdepth` | 0-1 | Modulation depth |
| `flangerfeedback` | 0-0.95 | Feedback amount |
## Chorus
Chorus uses multiple modulated delay lines with 120° phase offset for stereo width.
```forth
pad 1 chorus 0.4 chorusdepth 20 chorusdelay .
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `chorus` | Hz | Modulation rate |
| `chorusdepth` | 0-1 | Modulation depth |
| `chorusdelay` | ms | Base delay time |

126
docs/engine_samples.md Normal file
View File

@@ -0,0 +1,126 @@
# Samples
The `sample` source plays audio files from disk with pitch tracking.
## Loading Samples
There are two ways to load samples:
* **From the app:** Navigate to the Engine view and find the Samples section. Press `A` to open a file browser, then select a folder containing your samples. Press `D` to remove the last added path.
* **From the command line:** Use the `-s` flag when launching Cagire:
```
cagire -s ~/samples -s ~/more-samples
```
The engine scans these directories and builds a registry of available samples. Samples load in the background without blocking audio. Supported file formats are `.wav`, `.mp3`, `.ogg`, `.flac` and `.aiff`.
## Folder Organization
```
samples/
├── kick.wav → "kick"
├── snare.wav → "snare"
└── hats/
├── closed.wav → "hats" n 0
├── open.wav → "hats" n 1
└── pedal.wav → "hats" n 2
```
Folders at the root of your directory are used as the name of a sample bank. Folders create sample banks where each file gets an index. Files are sorted alphabetically and assigned indices starting from `0`.
## Playing Samples
```forth
kick sound . ( play kick sample )
hats sound 2 n . ( play third hat sample )
snare sound 0.5 speed . ( play snare at half speed )
```
## Parameters
| Parameter | Range | Description |
|-----------|-------|-------------|
| `n` | 0+ | Sample index within a folder (wraps around) |
| `begin` | 0-1 | Playback start position |
| `end` | 0-1 | Playback end position |
| `speed` | any | Playback speed multiplier |
| `freq` | Hz | Base frequency for pitch tracking |
| `fit` | seconds | Stretch/compress sample to fit duration |
| `cut` | 0+ | Choke group |
## Slicing with Begin/End
The `begin` and `end` parameters define what portion of the sample plays. Values are normalized from 0 (start) to 1 (end).
```forth
kick sound 0.25 begin 0.75 end . ( play middle half )
kick sound 0.5 begin . ( play second half )
kick sound 0.5 end . ( play first half )
```
If begin is greater than end, they swap automatically.
## Speed and Pitch
The `speed` parameter affects both tempo and pitch. A speed of 2 plays twice as fast and an octave higher.
```forth
snare sound 2 speed . ( double speed, octave up )
snare sound 0.5 speed . ( half speed, octave down )
snare sound -1 speed . ( play in reverse )
```
For pitched playback, use `freq` or note names. The sample's base frequency defaults to middle C (261.626 Hz).
```forth
kick sound 440 freq . ( play at A4 )
kick sound c4 . ( play at C4 )
```
Negative speed will reverse the sample and play it backwards.
```forth
crow sound -1 speed . ( play backwards at nominal speed )
crow sound -4 speed . ( play backwards, 4 times faster )
```
## Fitting to Duration
The `fit` parameter stretches or compresses a sample to match a target duration in seconds. This adjusts speed automatically.
```forth
kick sound 0.25 fit . ( fit kick into quarter second )
snare sound beat fit . ( fit snare to one beat )
```
## Choke Groups
The `cut` parameter assigns a sample to a choke group. When a new sample with the same cut value plays, it kills any currently playing samples in that group.
```forth
hihat_closed sound 1 cut . ( choke group 1 )
hihat_open sound 1 cut . ( kills closed hat, starts open )
```
This is essential for realistic hi-hat behavior where open and closed hats shouldn't overlap.
## Bank Variations
Add `_suffix` to sample names to create variations that share the same base name.
```
samples/
├── kick.wav
├── kick_hard.wav
├── kick_soft.wav
```
Select variations with the `bank` parameter:
```forth
kick sound . ( plays kick.wav )
kick sound hard bank . ( plays kick_hard.wav )
kick sound soft bank . ( plays kick_soft.wav )
```

126
docs/engine_settings.md Normal file
View File

@@ -0,0 +1,126 @@
# Settings
The audio engine can be configured through the Engine page or via command-line arguments. Settings are saved and restored between sessions.
## Engine Page
Press `Ctrl+Right` until you reach the Engine page. Here you can see the engine status and adjust settings.
### Display
The right side of the page shows visualizations:
- **Scope**: oscilloscope showing the audio waveform
- **Spectrum**: 32-band frequency analyzer
### Settings
Navigate with arrow keys, adjust values with left/right:
- **Output Device**: where sound goes (speakers, headphones, interface).
- **Input Device**: what audio input source to use (microphone, line-in, etc.).
- **Channels**: number of output channels (2 for stereo).
- **Buffer Size**: audio buffer in samples (64-4096).
- **Max Voices**: polyphony limit (1-128, default 32).
- **Lookahead**: scheduling lookahead in milliseconds (0-50, default 15).
### Buffer Size
Smaller buffers mean lower latency but higher CPU load. Larger buffers are safer but feel sluggish.
| Buffer | Latency at 44.1kHz |
|--------|-------------------|
| 64 | ~1.5ms |
| 128 | ~3ms |
| 256 | ~6ms |
| 512 | ~12ms |
| 1024 | ~23ms |
Start with 512. Lower it if you need tighter timing. Raise it if you hear glitches.
## Samples
The engine indexes audio files from your sample directories. Add directories with `A`, remove with `D`. The sample count shows how many files are indexed.
### Supported Formats
- WAV (.wav)
- MP3 (.mp3)
- OGG Vorbis (.ogg)
- FLAC (.flac)
- AIFF (.aiff, .aif)
- AAC (.aac)
- M4A (.m4a)
### Lazy Loading
Samples are not loaded into memory at startup. They are decoded on demand when first played. This means you can have thousands of samples indexed without using much RAM until you actually use them.
### Folder Organization
The scanner looks at top-level files and one level of subdirectories:
```
samples/
├── kick.wav -> "kick"
├── snare.wav -> "snare"
├── hats/
│ ├── closed.wav -> "hats" n 0
│ ├── open.wav -> "hats" n 1
│ └── pedal.wav -> "hats" n 2
└── breaks/
├── amen.wav -> "breaks" n 0
└── think.wav -> "breaks" n 1
```
Top-level files are named by their filename (without extension). Files inside folders are sorted alphabetically and numbered starting from 0.
### Playing Samples
Reference samples by name:
```forth
kick s . ;; play kick.wav
snare s 0.5 gain . ;; play snare at half volume
```
For samples in folders, use `n` to select which one:
```forth
hats s 0 n . ;; play hats/closed.wav (index 0)
hats s 1 n . ;; play hats/open.wav (index 1)
hats s 2 n . ;; play hats/pedal.wav (index 2)
```
The index wraps around. If you have 3 samples and request `5 n`, you get index 2 (because 5 % 3 = 2).
### Sample Variations with Bank
The `bank` parameter lets you organize variations:
```
samples/
├── kick.wav -> default
├── kick_a.wav -> bank "a"
├── kick_b.wav -> bank "b"
└── kick_hard.wav -> bank "hard"
```
```forth
kick s . ;; plays kick.wav
kick s a bank . ;; plays kick_a.wav
kick s hard bank . ;; plays kick_hard.wav
```
If the banked version does not exist, it falls back to the default.
## Troubleshooting
* **No sound**: Check output device selection.
* Try the test sound (`t`) on Engine page).
* **Glitches/crackling**: Increase buffer size, restart the Engine.
* **High CPU**: Reduce max voices. Disable scope/spectrum. Increase buffer size.
* **Samples not found**: Check sample directories on Engine page. Filenames are case-sensitive on some systems.

92
docs/engine_sources.md Normal file
View File

@@ -0,0 +1,92 @@
# Sources
The audio engine provides a variety of sound sources. Use the `sound` word (or `s` for short) to select one.
## Basic Oscillators
| Name | Description |
|------|-------------|
| `sine` | Pure sinusoid, smooth and mellow |
| `tri` | Triangle wave, warmer than sine, naturally band-limited |
| `saw` | Bright sawtooth with anti-aliasing, rich in harmonics |
| `zaw` | Raw sawtooth without anti-aliasing, lo-fi character |
| `pulse`, `square` | Variable-width pulse wave with anti-aliasing |
| `pulze`, `zquare` | Raw pulse without anti-aliasing, 8-bit feel |
`pulse` and `pulze` respond to the `pw` parameter (0.0-1.0) for pulse width. At 0.5 you get a square wave.
### Phase Shaping
All oscillators support phase shaping for timbral variation:
| Parameter | Range | Effect |
|-----------|-------|--------|
| `size` | 0-256 | Phase quantization (lo-fi, chiptune). |
| `mult` | 0.25-16 | Phase multiplier (harmonic overtones). |
| `warp` | -1 to 1 | Power curve asymmetry. |
| `mirror` | 0-1 | Phase reflection point. |
These are super useful to get the most out of your oscillators.
### Sub Oscillator
Add a sub oscillator layer to any basic oscillator:
| Parameter | Range | Effect |
|-----------|-------|--------|
| `sub` | 0-1 | Mix level |
| `suboct` | 1-3 | Octaves below main. |
| `subwave` | tri/sine/square | Sub waveform. |
## Noise
| Name | Description |
|------|-------------|
| `white` | Equal energy across all frequencies, bright and hissy. |
| `pink` | -3dB/octave rolloff, equal energy per octave, natural. |
| `brown` | -6dB/octave rolloff, deep rumbling, random walk. |
Noise sources ignore pitch. Use filters to shape the spectrum.
## Live Input
| Name | Description |
|------|-------------|
| `live`, `livein`, `mic` | Live audio input from microphone or line-in |
All filter and effect parameters apply to the input signal.
## Plaits Engines
The Plaits engines come from Mutable Instruments and provide a range of synthesis methods. Beware, these sources can be quite CPU hungry. All share three control parameters (`0.0`-`1.0`):
| Parameter | Controls |
|-----------|----------|
| `harmonics` | Harmonic content, structure, detuning. |
| `timbre` | Brightness, tonal color. |
| `morph` | Smooth transitions between variations. |
### Pitched
| Name | Description |
|------|-------------|
| `modal` | Struck/plucked resonant bodies (strings, plates, tubes). |
| `va`, `analog` | Virtual analog with waveform sync and crossfading. |
| `ws`, `waveshape` | Waveshaper and wavefolder. |
| `fm2` | Two-operator FM synthesis with feedback. |
| `grain` | Granular formant oscillator (vowel-like). |
| `additive` | Harmonic additive synthesis. |
| `wavetable` | Built-in Plaits wavetables (four 8x8 banks). |
| `chord` | Four-note chord generator. |
| `swarm` | Granular cloud of enveloped sawtooths. |
| `pnoise` | Clocked noise through multimode filter. |
### Percussion
| Name | Description |
|------|-------------|
| `kick`, `bass` | 808-style bass drum. |
| `snare` | Analog snare drum with tone/noise balance. |
| `hihat`, `hat` | Metallic 808-style hi-hat. |
Percussions are super hard to use correctly, because you need to tweak their envelope correctly.

118
docs/engine_space.md Normal file
View File

@@ -0,0 +1,118 @@
# Space & Time
Spatial effects position sounds in the stereo field and add depth through delays and reverbs.
## Pan
Pan positions a sound in the stereo field.
```forth
hat -0.5 pan . ( slightly left )
perc 1 pan . ( hard right )
kick 0 pan . ( center )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `pan` | -1 to 1 | Stereo position (-1 = left, 0 = center, 1 = right) |
## Width
Width controls the stereo spread using mid-side processing.
```forth
pad 1.5 width . ( wider stereo )
pad 0 width . ( mono )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `width` | 0+ | Stereo width (0 = mono, 1 = unchanged, 2 = exaggerated) |
## Haas Effect
The Haas effect delays one channel slightly, creating a sense of stereo width and spatial placement.
```forth
snare 15 haas . ( 15ms delay on right channel )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `haas` | ms | Delay time (1-10ms = subtle width, 10-35ms = distinct echo) |
## Delay
Delay is a send effect that creates echoes. The `delay` parameter sets how much signal is sent to the delay bus.
```forth
snare 0.3 delay 0.25 delaytime 0.5 delayfeedback .
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `delay` | 0-1 | Send level |
| `delaytime` | seconds | Delay time |
| `delayfeedback` | 0-0.95 | Feedback amount |
| `delaytype` | type | Delay algorithm |
### Delay Types
| Type | Description |
|------|-------------|
| `standard` | Clean digital repeats |
| `pingpong` | Bounces between left and right |
| `tape` | Each repeat gets darker (analog warmth) |
| `multitap` | 4 taps with swing control via feedback |
```forth
snare 0.4 delay pingpong delaytype .
pad 0.3 delay tape delaytype .
```
## Reverb
Reverb is a send effect that simulates acoustic spaces. The `verb` parameter sets the send level.
```forth
snare 0.2 verb 2 verbdecay .
pad 0.4 verb 4 verbdecay 0.7 verbdamp .
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `verb` | 0-1 | Send level |
| `verbdecay` | seconds | Reverb tail length |
| `verbdamp` | 0-1 | High frequency damping |
| `verbpredelay` | ms | Initial delay before reverb |
| `verbdiff` | 0-1 | Diffusion (smears transients) |
| `verbtype` | type | Reverb algorithm |
### Reverb Types
| Type | Description |
|------|-------------|
| `dattorro` | Plate reverb, bright and metallic shimmer |
| `fdn` | Hall reverb, dense and smooth |
```forth
snare 0.3 verb dattorro verbtype . ( plate )
pad 0.5 verb fdn verbtype . ( hall )
```
## Comb Filter
The comb filter creates resonant pitched delays, useful for Karplus-Strong string synthesis and metallic tones.
```forth
white 0.5 comb 220 combfreq 0.9 combfeedback . ( plucked string )
```
| Parameter | Range | Description |
|-----------|-------|-------------|
| `comb` | 0-1 | Send level |
| `combfreq` | Hz | Resonant frequency |
| `combfeedback` | 0-0.99 | Feedback (higher = longer decay) |
| `combdamp` | 0-1 | High frequency damping |
Higher feedback creates longer, ringing tones. Add damping for more natural string-like decay.

87
docs/engine_wavetable.md Normal file
View File

@@ -0,0 +1,87 @@
# Wavetables
Any sample can be played as a wavetable. When you use the `scan` parameter, the sample automatically becomes a pitched oscillator that can morph between cycles.
## What is a Wavetable?
A wavetable is a series of single-cycle waveforms stored end-to-end in an audio file. Each cycle is a complete waveform that starts and ends at zero crossing, allowing it to loop seamlessly at any pitch.
```
Sample: [cycle 0][cycle 1][cycle 2][cycle 3]
↑ ↑
scan 0 scan 1
```
The oscillator reads through one cycle at audio rate (determining pitch), while `scan` selects which cycle to play. Values between cycles crossfade smoothly, creating timbral morphing.
## Basic Usage
Just add `scan` to any sample and it becomes a wavetable:
```forth
pad 0 scan . ( play pad as wavetable, first cycle )
pad 0.5 scan . ( blend to middle cycles )
pad 440 freq 0 scan . ( play at A4 )
```
Without `scan`, the sample plays normally. With `scan`, it becomes a looping wavetable oscillator.
## Parameters
| Parameter | Range | Description |
|-----------|-------|-------------|
| `scan` | 0-1 | Position in wavetable (0 = first cycle, 1 = last) |
| `wtlen` | samples | Cycle length in samples (0 = entire sample) |
| `scanlfo` | Hz | LFO rate for scan modulation |
| `scandepth` | 0-1 | LFO modulation depth |
| `scanshape` | shape | LFO waveform |
## Cycle Length
The `wtlen` parameter tells the engine how many samples make up one cycle. This must match how the wavetable was created, otherwise you'll hear the wrong pitch or glitchy artifacts.
```forth
pad 0 scan 2048 wtlen . ( Serum-style 2048-sample cycles )
pad 0 scan 1024 wtlen . ( 1024-sample cycles )
```
Common cycle lengths are powers of two: 256, 512, 1024, 2048. Serum uses 2048 samples per cycle. The number of cycles in a wavetable is the total sample length divided by `wtlen`. If `wtlen` is 0 (default), the entire sample is treated as one cycle. The sample still loops as a pitched oscillator, but `scan` has no morphing effect since there's only one cycle.
## Scanning
The `scan` parameter selects which cycle to play:
```forth
pad 0 scan . ( first cycle only )
pad 0.5 scan . ( blend between middle cycles )
pad 1 scan . ( last cycle only )
```
## LFO Modulation
Automate the scan position with a built-in LFO:
```forth
pad 0 scan 2 scanlfo 0.3 scandepth . ( 2 Hz modulation, 30% depth )
```
Available LFO shapes:
| Shape | Description |
|-------|-------------|
| `sine` | Smooth oscillation (default) |
| `tri` | Triangle wave |
| `saw` | Sawtooth, ramps up |
| `square` | Alternates between extremes |
| `sh` | Sample and hold, random steps |
## Creating Wavetables
A proper wavetable file:
- Contains multiple single-cycle waveforms of identical length
- Each cycle starts and ends at zero crossing for seamless looping
- Uses power-of-two cycle lengths (256, 512, 1024, 2048)
- Has cycles that morph smoothly from one to the next
You can find wavetable packs online or create your own in tools like Serum, WaveEdit, or Audacity (using zero-crossing snap). Single-cycle waveforms also work. With `wtlen` set to 0, a single-cycle sample becomes a basic pitched oscillator.

43
docs/engine_words.md Normal file
View File

@@ -0,0 +1,43 @@
# Words & Sounds
Word definitions let you abstract sound design into reusable units.
## Defining Sounds
```forth
: lead "saw" s 0.3 gain 1200 lpf ;
```
Use it with different notes:
```forth
c4 note lead .
e4 note lead .
```
## Self-Contained Words
Include the emit to make the word play directly:
```forth
: kk "kick" s 1 decay . ;
: hh "hihat" s 0.5 gain 0.5 decay . ;
```
Steps become simple:
```forth
kk
0.5 at hh
```
## Effect Presets
```forth
: dark 800 lpf 0.6 lpq ;
: wet 0.7 verb 8 verbdiff ;
```
```forth
c4 note saw s dark wet .
```

52
docs/grid.md Normal file
View File

@@ -0,0 +1,52 @@
# The Sequencer Grid
The sequencer grid is the main view of Cagire. This is the one you see when you open the application. On this view, you can see the step sequencer grid and edit each step using the `code editor`. At the top, you can optionally display an oscilloscope and a spectrum analyzer.
## Navigation
Use arrow keys to move between steps. The grid wraps around at pattern boundaries. You can move in any direction.
## Preview
Press `P` to enter preview mode. In preview mode, a view-only code editor opens so that you can see the script of the currently playing step. While in preview mode, you can still move around the grid. Press `Esc` to exit preview mode.
## Selection
Hold `Shift` while pressing arrow keys to select multiple steps. Press `Esc` to clear the selection.
## Editing Steps
- `Enter` - Open the script editor.
- `t` - Toggle step active/inactive.
- `r` - Rename a step.
- `Del` - Delete selected steps.
## Copy & Paste
- `Ctrl+C` - Copy selected steps.
- `Ctrl+V` - Paste as copies.
- `Ctrl+B` - Paste as linked steps.
- `Ctrl+D` - Duplicate selection.
- `Ctrl+H` - Harden links (convert to copies).
`Linked steps` share the same script as their source. When you edit the source, all linked steps update automatically. This is an extremely important and powerful feature. It allows you to create complex patterns with minimal effort. `Ctrl+H` is your best friend to manage linked steps and convert them to real steps.
## Pattern Controls
- `<` / `>` - Decrease/increase pattern length
- `[` / `]` - Decrease/increase pattern speed
- `L` - Set length directly
- `S` - Set speed directly
## Playback
- `Space` - Toggle play/stop
- `+` / `-` - Adjust tempo
- `T` - Set tempo directly
- `Ctrl+R` - Run current step once (preview)
## Visual Indicators
- **Highlighted cell** - Currently playing step
- **Colored backgrounds** - Linked steps share colors by source
- **Arrow prefix** (`→05`) - Step is linked to step 05

58
docs/how_it_works.md Normal file
View File

@@ -0,0 +1,58 @@
# How Does It Work?
Cagire is a step sequencer where each step contains a **Forth script** instead of the typical note data. When the sequencer reaches a step, it runs the script. A script _can do whatever it is programed to do_, such as producing sound commands sent to an internal audio engine. Everything else is similar to a step sequencer: you can `toggle` / `untoggle`, `copy` / `paste` any step or group of steps, etc. You are completely free to define what your scripts will do. It can be as simple as playing a note, or as complex as triggering random audio samples with complex effects. Scripts can also share code and data with each other.
## Project / session organization
Cagire can run multiple patterns concurrently. Each pattern contains a given number of steps. Every session / project is organized hierarchically:
- **32 Banks**
- **32 Patterns** per bank
- **128 Steps** per pattern
That's over 130,000 possible steps per project. Most of my sessions use 15-20 at best.
## What does a script look like?
Forth is a stack-based programming language. It is very minimalistic and emphasizes simplicity and readability. Using Forth doesn't feel like programming at all. It feels more like juggling with words and numbers or writing bad computer poetry. There is pretty much no syntax to learn, just a few rules to follow. Forth is ancient, powerful, flexible, and... super fun to live code with! Here is a minimal program that will play a middle C note using a sine wave:
```forth
c4 note sine sound .
```
Read the program backwards and you will understand what it does instantly:
- `.`: we want to play a sound.
- `sine sound`: the sound is a sinewave.
- `c4 note`: the pitch is C4 (middle-C).
Scripts can be simple one-liners or complex programs with conditionals, loops, and randomness. They tend to look like an accumulation of words and numbers. Use space and line returns to your advantage. The Forth language can be learned... on the spot. You just need to understand the following basic rules:
- there are `words` and `numbers`.
- they are delimited by spaces.
- everything piles up on the `stack`.
Obviously you will need to understand what the **stack** is, but it will take you five minutes. That's it. See the **Forth** section for details.
## The Audio Engine
Cagire includes a complete synthesis engine. No external software is required to play music. It comes with a large number of sound sources and sound shaping tools: oscillators, sample players, effects, filters, and more. The audio engine is quite capable and versatile, and can accomodate a vast array of genres / styles. Here are a few examples :
```forth
;; sawtooth wave with lowpass filter, chorus and reverb
saw sound 1200 lpf 0.2 chorus 0.8 verb .
```
```forth
;; pure sine wave with vibrato and bit crushing
0.5 vibmod 4 vib sine sound 8 crush 0.8 gain .
```
```forth
;; very loud and pitched-down kick drum using an audio sample
kkick sound 1.5 distort 0.9 postgain 0.8 speed .
```
## Timing & Synchronization
Cagire uses **Ableton Link** to manage timing and synchronization. This means that all devices using the same protocol can be synchronized to the same tempo. Most commercial softwares support this protocol. The playback speed is defined as a BPM (beats per minute) value. Patterns can run at different speeds relative to the master tempo. Most of the durations in Cagire are defined in terms of beats.

View File

@@ -1,58 +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
### Tempo
- **+ / =**: Increase tempo
- **-**: Decrease tempo
## Main Page - Editor Focus
- **Tab / Esc**: Return to sequencer focus
- **Ctrl+E**: Compile current step script
## 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
View 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
View 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
View 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.

39
docs/navigation.md Normal file
View File

@@ -0,0 +1,39 @@
# Navigation
The Cagire application is organized as a grid composed of six views:
```
Dict Patterns Options
Help Sequencer Engine
```
- `Dict` (Dictionary): A comprehensive list of all the `Forth` words used in the application.
- `Help`: Provides detailed information about the application's features and functionalities.
- `Patterns`: Pattern banks and pattern manager. Used to organize a session / project.
- `Sequencer`: The main view, where you edit sequences and play music.
- `Options`: Configuration settings for the application.
- `Engine`: Configuration settings for the internal audio engine.
## Switching Views
Use `Ctrl+Arrow` keys to move between views. A minimap appears briefly showing your position in the grid.
- `Ctrl+Left` / `Ctrl+Right` - move horizontally
- `Ctrl+Up` / `Ctrl+Down` - move vertically
The grid wraps horizontally, so you can cycle through views on the same row.
## Getting Help
Press `?` on any view to see its keybindings. This shows all available shortcuts for the current context.
Press `Esc` to close the keybindings panel.
## Common Keys
These work on most views:
- `Arrow keys` - move or scroll
- `Tab` - switch focus between panels
- `/` or `Ctrl+f` - search (where available)
- `q` - quit (with confirmation)

235
docs/oddities.md Normal file
View File

@@ -0,0 +1,235 @@
# Oddities
Cagire's Forth is not a classic Forth. It borrows the core ideas (stack-based evaluation, postfix notation, word definitions) but adds modern features and domain-specific extensions. If you know traditional Forth, here are the differences.
## Comments
Classic Forth uses parentheses for comments:
```forth
( this is a comment )
```
Cagire uses double semicolons:
```forth
;; this is a comment
```
Everything after `;;` until the end of the line is ignored.
## Quotations
Classic Forth has no quotations. Code is not a value you can pass around.
Cagire has first-class quotations using curly braces:
```forth
{ dup + }
```
This pushes a block of code onto the stack. You can store it, pass it to other words, and execute it later. Quotations enable conditionals, probability, and cycling.
## Conditionals
Classic Forth uses `IF ... ELSE ... THEN`:
```forth
x 0 > IF 1 ELSE -1 THEN
```
Cagire supports this syntax but also provides quotation-based conditionals:
```forth
{ 1 } { -1 } x 0 > ifelse
```
The words `?` and `!?` execute a quotation based on a condition:
```forth
{ "kick" s . } coin ? ;; execute if coin is 1
{ "snare" s . } coin !? ;; execute if coin is 0
```
## Strings
Classic Forth has limited string support. You print strings with `."`:
```forth
." Hello World"
```
Cagire has first-class strings:
```forth
"hello"
```
This pushes a string value onto the stack. Strings are used for sound names, sample names, and variable keys. You often do not need quotes at all. Any unrecognized word becomes a string automatically:
```forth
kick s . ;; "kick" is not a word, so it becomes the string "kick"
myweirdname ;; pushes "myweirdname" onto the stack
```
This makes scripts cleaner. You only need quotes when the string contains spaces or conflicts with a real word.
## Variables
Classic Forth declares variables explicitly:
```forth
VARIABLE x
10 x !
x @
```
Cagire uses prefix syntax:
```forth
10 !x ;; store 10 in x
@x ;; fetch x (returns 0 if undefined)
```
No declaration needed. Variables spring into existence when you store to them.
## Floating Point
Classic Forth (in its original form) has no floating point. Numbers are integers. Floating point was added later as an optional extension with separate words. Cagire has native floating point:
```forth
3.14159
0.5 0.3 + ;; 0.8
```
Integers and floats mix freely. Division always produces a float.
## Loops
Classic Forth has `DO ... LOOP`:
```forth
10 0 DO I . LOOP
```
Cagire uses a quotation-based loop with `times`:
```forth
4 { @i . } times ;; prints 0 1 2 3
```
The loop counter is stored in the variable `i`, accessed with `@i`. This fits Cagire's style where control flow uses quotations.
```forth
4 { @i 4 / at hat s . } times ;; hat at 0, 0.25, 0.5, 0.75
4 { c4 @i + note sine s . } times ;; ascending notes
```
For generating sequences without side effects, use `..` or `gen`:
```forth
1 5 .. ;; pushes 1 2 3 4 5
{ dup * } 4 gen ;; pushes 0 1 4 9 (squares)
```
## The Command Register
This is completely unique to Cagire. Traditional Forth programs print text. Cagire programs build sound commands.
The command register accumulates a sound name and parameters:
```forth
"sine" sound ;; set sound
440 freq ;; add parameter
0.5 gain ;; add parameter
. ;; emit and clear
```
Nothing is sent to the audio engine until you emit with `.`. This is unlike any classic Forth.
## Context Words
Cagire provides words that read the current sequencer state:
```forth
step ;; current step index (0-127)
beat ;; current beat position
iter ;; pattern iteration count
tempo ;; current BPM
phase ;; position in bar (0-1)
```
These have no equivalent in classic Forth. They connect your script to the sequencer's timeline.
## Probability
Classic Forth is deterministic. Cagire has built-in randomness:
```forth
{ "snare" s . } 50 prob ;; 50% chance
{ "clap" s . } 0.25 chance ;; 25% chance
{ "hat" s . } often ;; 75% chance
{ "rim" s . } sometimes ;; 50% chance
{ "tom" s . } rarely ;; 25% chance
```
These words take a quotation and execute it probabilistically.
## Cycling
Cagire has built-in support for cycling through values. Push values onto the stack, then select one based on pattern state:
```forth
60 64 67 3 cycle note
```
Each time the step runs, a different note is selected. The `3` tells `cycle` how many values to pick from.
You can also use quotations if you need to execute code:
```forth
{ c4 note } { e4 note } { g4 note } 3 cycle
```
When the selected value is a quotation, it gets executed. When it is a plain value, it gets pushed onto the stack.
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)
The difference between `cycle` and `pcycle` matters when patterns have different lengths. `cycle` counts per-step, `pcycle` counts per-pattern.
## 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
60 64 67 note sine s . ;; emits 3 voices with notes 60, 64, 67
```
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
Cagire's Forth is a domain-specific language for music. It keeps Forth's elegance (stack, postfix, definitions) but adapts it for live coding.

View File

@@ -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`

95
docs/stack.md Normal file
View File

@@ -0,0 +1,95 @@
# The Stack
The stack is the heart of Forth. Every value you type goes onto the stack. Every word you call takes values from the stack and puts results back. There are no variables in the traditional sense, just this pile of values that grows and shrinks as your program runs.
## Pushing Values
When you type a number or a string, it goes on top of the stack:
| Input | Stack (top on right) |
|-------|---------------------|
| `3` | `3` |
| `4` | `3 4` |
| `5` | `3 4 5` |
The stack grows to the right. The rightmost value is the top.
## Words Consume and Produce
Words take values from the top and push results back. The `+` word pops two numbers and pushes their sum:
| Input | Stack |
|-------|-------|
| `3` | `3` |
| `4` | `3 4` |
| `+` | `7` |
This is why Forth uses postfix notation: operands come first, then the operator.
## Stack Notation
Documentation describes what words do using stack effect notation:
```
( before -- after )
```
The word `+` has the effect `( a b -- sum )`. It takes two values and leaves one.
The word `dup` has the effect `( a -- a a )`. It takes one value and leaves two.
## Thinking in Stack
The key to Forth is learning to visualize the stack as you write. Consider this program:
| Input | Stack | What happens |
|-------|-------|--------------|
| `3` | `3` | Push 3 |
| `4` | `3 4` | Push 4 |
| `+` | `7` | Add them |
| `2` | `7 2` | Push 2 |
| `*` | `14` | Multiply |
This computes `(3 + 4) * 2`. The parentheses are implicit in the order of operations. You can use line breaks and white spaces to keep organized, and the editor will also show the stack at each step for you if you ask it nicely :)
## Rearranging Values
Sometimes you need values in a different order. Stack manipulation words like `dup`, `swap`, `drop`, and `over` let you shuffle things around. You will find them in the dictionary. Here is a common pattern. You want to use a value twice:
| Input | Stack |
|-------|-------|
| `3` | `3` |
| `dup` | `3 3` |
| `+` | `6` |
The word `dup` duplicates the top, so `3 dup +` doubles the number.
Another pattern. You have two values but need them swapped:
| Input | Stack |
|-------|-------|
| `3` | `3` |
| `4` | `3 4` |
| `swap` | `4 3` |
| `-` | `1` |
Without `swap`, `3 4 -` would compute `3 - 4 = -1`. With `swap`, you get `4 - 3 = 1`.
## Stack Errors
Two things can go wrong with the stack:
* **Stack underflow** happens when a word needs more values than the stack has. If you write `+` with only one number on the stack, there is nothing to add. The script stops with an error.
```forth
3 + ;; error: stack underflow
```
The fix is simple: make sure you push enough values before calling a word. Check the stack effect in the dictionary if you are unsure.
* **Stack overflow** is the opposite: too many values left on the stack. This is less critical but indicates sloppy code. If your script leaves unused values behind, you probably made a mistake somewhere.
```forth
3 4 5 + . ;; plays a sound, but 3 is still on the stack
```
The `3` was never used. Either it should not be there, or you forgot a word that consumes it.

50
docs/staging.md Normal file
View File

@@ -0,0 +1,50 @@
# Stage / Commit
Cagire requires you to `stage` changes you wish to make to the playback state and then `commit` it. It is way more simple than it seems. For instance, you mark pattern `04` and `05` to start playing, and _then_ you send the order to start the playback (`commit`). The same goes for stopping patterns. You mark which pattern to stop (`stage`) and then you give the order to stop them (`commit`). Why is staging useful? Here are some reasons why this design choice was made:
- **To apply multiple changes**: Queue several patterns to start/stop, commit them together.
- **To get clean timing**: All changes happen on beat/bar boundaries.
- **To help with live performance**: Prepare the next section without affecting current playback.
Staging is an essential feature to understand to be effective when doing live performances:
1. Open the **Patterns** view (`Ctrl+Up` from sequencer)
2. Navigate to a pattern you wish to change/play
3. Press `Space` to stage it. The pending change is going to be displayed:
- `+` (staged to play)
- `-` (staged to stop)
4. Repeat for other patterns you want to change
5. Press `c` to commit all changes
6. Or press `Esc` to cancel
A pattern might not start immediately depending on the sync mode you have chosen. It might wait for the next beat/bar boundary.
## Status Indicators
| Indicator | Meaning |
|-----------|---------|
| `>` | Currently playing |
| `+` | Staged to play |
| `-` | Staged to stop |
A pattern can show both `>` (playing) and `-` (staged to stop).
## Quantization
Committed changes don't execute immediately. They wait for a quantization boundary:
| Setting | Behavior |
|---------|----------|
| Immediate | Next sequencer tick |
| Beat | Next beat |
| 1 Bar | Next bar (default) |
| 2/4/8 Bars | Next 2, 4, or 8-bar boundary |
Edit quantization in pattern properties (press `e` on a pattern).
## Sync Mode
When a pattern starts, its playback position depends on sync mode:
- **Reset**: Always start at step 0
- **Phase-Lock**: Start at the current beat-aligned position (stays in sync with other patterns)

View File

@@ -1,19 +1,31 @@
# Welcome to Cagire
Cagire is a terminal-based step sequencer for live coding music. Each step in a pattern contains a **Forth** script that produces sound and create events. It is made by BuboBubo (Raphaël Maurice Forment): [https://raphaelforment.fr](https://raphaelforment.fr). Cagire is open-source (AGPL-3.0 licensed) and available on GitHub : [https://github.com/BuboBubo/cagire](https://github.com/BuboBubo/cagire). This help view will teach you everything you need to know to start using Cagire and and to live code with it. To use Cagire, you will need to understand two things:
Cagire is a terminal-based step sequencer for live coding music. Each step on the sequencer is defined by a **Forth** script that produces sound and create events. The documentation you are currently reading acts both as _tutorial_ and _reference_. It contains everything you need to know to use Cagire effectively. We recommend you to dive in and explore by picking subjects that interest you before slowly learning about everything else. Here are some recommended topics to start with:
1) How the sequencer works: dealing with steps, patterns and banks.
2) How to write a script: how to make sound using code.
1) How the sequencer works? Banks, patterns and steps.
* the sequencer model, the pattern model, the step sequencer.
2) How to write a script? How to make sound using code.
* how to write simple scripts that play `musical events`.
* how to extend these scripts with `logic` and/or `randomness`.
* how define `WORDS`, `variables`, and share data between steps.
3) What can I do with the audio engine?
* audio sources: samples, oscillators, wavetables, noise generators.
* audio effects: filters, delay, reverb, distortion, modulations.
4) How far can it go?
* how to live code with Cagire.
* how fast can I break things?
## Pages
## What is live coding?
Cagire is organized in several views. Navigate between them using **Ctrl+Left/Right/Up/Down**:
Live coding is a technique where a programmer writes code in real-time in front of an audience. It is a way to experiment with code, to share things and thoughts openly, to express yourself through code. It can be technical, poetical, weird, preferably all at once. Live coding can be used to create music, visual art, and other forms of media. Learn more about live coding on [https://toplap.org](https://toplap.org) or [https://livecoding.fr](https://livecoding.fr). Live coding is an autotelic activity: it is an activity that is intrinsically rewarding, and the act of doing it is its own reward. There are no errors, only fun.
- **Sequencer**: Main view. Edit or preview patterns and scripts. Write Forth scripts.
- **Patterns**: Project patterns management. 32 banks of 32 patterns per project. Edit pattern properties (name, length, etc).
- **Engine**: Internal audio engine management: device selection, sample loading, performance options and voice / state monitoring.
- **Options**: General application settings.
- **Dict**: Forth word dictionary, organized by category. Learn about the internal programming language and its features.
- **Help**: Documentation. This is the page view you are looking at right now.
## About
Have fun with Cagire! Remember that live coding is all about experimentation and exploration!
Cagire is built by BuboBubo (Raphaël Maurice Forment, [https://raphaelforment.fr](https://raphaelforment.fr)). It is a free and open-source project licensed under the `AGPL-3.0 License`. You are free to contribute to the project by making direct contributions to the codebase or by providing feedback and suggestions.
### Credits
* **Doux** (audio engine) is a Rust port of Dough, originally written in C by Felix Roos.
* **mi-plaits-dsp-rs** is a Rust port of the code used by the Mutable Instruments Plaits.
* _Author_: Oliver Rockstedt [info@sourcebox.de](info@sourcebox.de).
* _Original author_: Emilie Gillet [emilie.o.gillet@gmail.com](emilie.o.gillet@gmail.com).

6
release.toml Normal file
View File

@@ -0,0 +1,6 @@
allow-branch = ["main"]
sign-commit = false
sign-tag = false
push = true
publish = false
tag-name = "v{{version}}"

View File

@@ -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(
@@ -281,6 +337,22 @@ impl App {
self.project_state.mark_dirty(change.bank, change.pattern);
}
pub fn execute_script_oneshot(
&self,
script: &str,
link: &LinkState,
audio_tx: &arc_swap::ArcSwap<Sender<crate::engine::AudioCommand>>,
) -> Result<(), String> {
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
.load()
.send(crate::engine::AudioCommand::Evaluate { cmd, time: None });
}
Ok(())
}
pub fn compile_current_step(&mut self, link: &LinkState) {
let step_idx = self.editor_ctx.step;
let (bank, pattern) = self.current_bank_pattern();
@@ -301,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) => {
@@ -378,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
@@ -493,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) => {
@@ -512,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);
}
@@ -632,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 {
@@ -655,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);
@@ -673,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
@@ -713,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();
@@ -753,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) {
@@ -772,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;
@@ -802,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) {
@@ -814,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;
}
@@ -835,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;
@@ -845,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;
@@ -875,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() {
@@ -901,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) {
@@ -941,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(),
@@ -987,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,
@@ -1029,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();
}
@@ -1056,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
@@ -1117,12 +1174,20 @@ impl App {
AppCommand::PageDown => self.page.down(),
// Help navigation
AppCommand::HelpNextTopic => {
self.ui.help_topic = (self.ui.help_topic + 1) % help_view::topic_count();
AppCommand::HelpToggleFocus => {
use crate::state::HelpFocus;
self.ui.help_focus = match self.ui.help_focus {
HelpFocus::Topics => HelpFocus::Content,
HelpFocus::Content => HelpFocus::Topics,
};
}
AppCommand::HelpPrevTopic => {
AppCommand::HelpNextTopic(n) => {
let count = help_view::topic_count();
self.ui.help_topic = (self.ui.help_topic + count - 1) % count;
self.ui.help_topic = (self.ui.help_topic + n) % count;
}
AppCommand::HelpPrevTopic(n) => {
let count = help_view::topic_count();
self.ui.help_topic = (self.ui.help_topic + count - (n % count)) % count;
}
AppCommand::HelpScrollDown(n) => {
let s = self.ui.help_scroll_mut();
@@ -1170,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;
@@ -1190,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;
@@ -1232,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;
}
}
}

View File

@@ -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);

View File

@@ -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),
@@ -138,8 +124,9 @@ pub enum AppCommand {
PageDown,
// Help navigation
HelpNextTopic,
HelpPrevTopic,
HelpToggleFocus,
HelpNextTopic(usize),
HelpPrevTopic(usize),
HelpScrollDown(usize),
HelpScrollUp(usize),
HelpActivateSearch,
@@ -168,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,
}

View File

@@ -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);
}
}
}

View File

@@ -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,
};

View File

@@ -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,
}
}

View File

@@ -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,7 +507,24 @@ 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,
),
}
}
KeyCode::Char('a') if ctrl => {
editor.select_all();
@@ -738,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);
}
_ => {}
}
@@ -776,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);
}
@@ -899,13 +916,36 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
}));
}
}
KeyCode::Char('r') if ctrl => {
let pattern = ctx.app.current_edit_pattern();
if let Some(script) = pattern.resolve_script(ctx.app.editor_ctx.step) {
if !script.trim().is_empty() {
match ctx
.app
.execute_script_oneshot(script, ctx.link, ctx.audio_tx)
{
Ok(()) => ctx
.app
.ui
.flash("Executed", 100, crate::state::FlashKind::Info),
Err(e) => ctx.app.ui.flash(
&format!("Error: {e}"),
200,
crate::state::FlashKind::Error,
),
}
}
}
}
KeyCode::Char('r') => {
let (bank, pattern, step) = (
ctx.app.editor_ctx.bank,
ctx.app.editor_ctx.pattern,
ctx.app.editor_ctx.step,
);
let current_name = ctx.app.current_edit_pattern()
let current_name = ctx
.app
.current_edit_pattern()
.step(step)
.and_then(|s| s.name.clone())
.unwrap_or_default();
@@ -1045,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 => {}
},
@@ -1061,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(),
}
}
@@ -1086,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));
}
}
}
@@ -1101,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);
}
}
@@ -1119,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);
}
@@ -1143,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);
}
@@ -1165,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());
@@ -1173,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!(
@@ -1191,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(),
@@ -1213,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 => {
@@ -1223,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
@@ -1252,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);
}
@@ -1269,26 +1441,43 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
}
fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
use crate::state::HelpFocus;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctx.app.ui.help_search_active {
match key.code {
KeyCode::Esc => ctx.dispatch(AppCommand::HelpClearSearch),
KeyCode::Enter => ctx.dispatch(AppCommand::HelpSearchConfirm),
KeyCode::Backspace => ctx.dispatch(AppCommand::HelpSearchBackspace),
KeyCode::Char(c) => ctx.dispatch(AppCommand::HelpSearchInput(c)),
KeyCode::Char(c) if !ctrl => ctx.dispatch(AppCommand::HelpSearchInput(c)),
_ => {}
}
return InputResult::Continue;
}
match key.code {
KeyCode::Char('/') => ctx.dispatch(AppCommand::HelpActivateSearch),
KeyCode::Char('/') | KeyCode::Char('f') if key.code == KeyCode::Char('/') || ctrl => {
ctx.dispatch(AppCommand::HelpActivateSearch);
}
KeyCode::Esc if !ctx.app.ui.help_search_query.is_empty() => {
ctx.dispatch(AppCommand::HelpClearSearch);
}
KeyCode::Char('j') | KeyCode::Down => ctx.dispatch(AppCommand::HelpScrollDown(1)),
KeyCode::Char('k') | KeyCode::Up => ctx.dispatch(AppCommand::HelpScrollUp(1)),
KeyCode::Tab => ctx.dispatch(AppCommand::HelpNextTopic),
KeyCode::BackTab => ctx.dispatch(AppCommand::HelpPrevTopic),
KeyCode::Tab => ctx.dispatch(AppCommand::HelpToggleFocus),
KeyCode::Char('j') | KeyCode::Down if ctrl => {
ctx.dispatch(AppCommand::HelpNextTopic(5));
}
KeyCode::Char('k') | KeyCode::Up if ctrl => {
ctx.dispatch(AppCommand::HelpPrevTopic(5));
}
KeyCode::Char('j') | KeyCode::Down => match ctx.app.ui.help_focus {
HelpFocus::Topics => ctx.dispatch(AppCommand::HelpNextTopic(1)),
HelpFocus::Content => ctx.dispatch(AppCommand::HelpScrollDown(1)),
},
KeyCode::Char('k') | KeyCode::Up => match ctx.app.ui.help_focus {
HelpFocus::Topics => ctx.dispatch(AppCommand::HelpPrevTopic(1)),
HelpFocus::Content => ctx.dispatch(AppCommand::HelpScrollUp(1)),
},
KeyCode::PageDown => ctx.dispatch(AppCommand::HelpScrollDown(10)),
KeyCode::PageUp => ctx.dispatch(AppCommand::HelpScrollUp(10)),
KeyCode::Char('q') => {

View File

@@ -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;

View File

@@ -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
View 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]);
}
}

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -26,16 +26,16 @@ impl Page {
/// Grid position (col, row) for each page
/// Layout:
/// col 0 col 1 col 2
/// row 0 Options Patterns Help
/// row 1 Dict Sequencer Engine
/// row 0 Dict Patterns Options
/// row 1 Help Sequencer Engine
pub const fn grid_pos(self) -> (i8, i8) {
match self {
Page::Options => (0, 0),
Page::Dict => (0, 1),
Page::Main => (1, 1),
Page::Dict => (0, 0),
Page::Help => (0, 1),
Page::Patterns => (1, 0),
Page::Main => (1, 1),
Page::Options => (2, 0),
Page::Engine => (2, 1),
Page::Help => (2, 0),
}
}

View File

@@ -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(

View File

@@ -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)]

View File

@@ -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 {

View File

@@ -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}")))
}
}

View File

@@ -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,

View File

@@ -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,13 +29,15 @@ 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};
pub use project::ProjectState;
pub use sample_browser::SampleBrowserState;
pub use ui::{DictFocus, FlashKind, UiState};
pub use ui::{DictFocus, FlashKind, HelpFocus, UiState};

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -19,19 +19,27 @@ pub enum DictFocus {
Words,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum HelpFocus {
#[default]
Topics,
Content,
}
pub struct UiState {
pub sparkles: Sparkles,
pub status_message: Option<String>,
pub flash_until: Option<Instant>,
pub flash_kind: FlashKind,
pub modal: Modal,
pub help_focus: HelpFocus,
pub help_topic: usize,
pub help_scrolls: Vec<usize>,
pub help_search_active: bool,
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,
@@ -52,13 +60,14 @@ impl Default for UiState {
flash_until: None,
flash_kind: FlashKind::Success,
modal: Modal::None,
help_focus: HelpFocus::default(),
help_topic: 0,
help_scrolls: vec![0; crate::views::help_view::topic_count()],
help_search_active: false,
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,
@@ -82,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));

View File

@@ -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()
}

View File

@@ -1,31 +1,159 @@
use minimad::{Composite, CompositeStyle, Compound, Line};
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;
use tui_big_text::{BigText, PixelSize};
use crate::app::App;
use crate::state::HelpFocus;
use crate::theme;
use crate::views::highlight;
// To add a new help topic: drop a .md file in docs/ and add one line here.
const DOCS: &[(&str, &str)] = &[
("Welcome", include_str!("../../docs/welcome.md")),
("Audio Engine", include_str!("../../docs/audio_engine.md")),
("Keybindings", include_str!("../../docs/keybindings.md")),
("Sequencer", include_str!("../../docs/sequencer.md")),
("About", include_str!("../../docs/about.md")),
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),
}
use DocEntry::{Section, Topic};
const DOCS: &[DocEntry] = &[
// Getting Started
Section("Getting Started"),
Topic("Welcome", include_str!("../../docs/welcome.md")),
Topic("Moving Around", include_str!("../../docs/navigation.md")),
Topic(
"How Does It Work?",
include_str!("../../docs/how_it_works.md"),
),
Topic(
"Banks & Patterns",
include_str!("../../docs/banks_patterns.md"),
),
Topic("Stage / Commit", include_str!("../../docs/staging.md")),
Topic("Using the Sequencer", include_str!("../../docs/grid.md")),
Topic("Editing a Step", include_str!("../../docs/editing.md")),
// Forth fundamentals
Section("Forth"),
Topic("About Forth", include_str!("../../docs/about_forth.md")),
Topic("The Dictionary", include_str!("../../docs/dictionary.md")),
Topic("The Stack", include_str!("../../docs/stack.md")),
Topic("Creating Words", include_str!("../../docs/definitions.md")),
Topic("Oddities", include_str!("../../docs/oddities.md")),
// Audio Engine
Section("Audio Engine"),
Topic("Introduction", include_str!("../../docs/engine_intro.md")),
Topic("Settings", include_str!("../../docs/engine_settings.md")),
Topic("Sources", include_str!("../../docs/engine_sources.md")),
Topic("Samples", include_str!("../../docs/engine_samples.md")),
Topic("Wavetables", include_str!("../../docs/engine_wavetable.md")),
Topic("Filters", include_str!("../../docs/engine_filters.md")),
Topic(
"Modulation",
include_str!("../../docs/engine_modulation.md"),
),
Topic(
"Distortion",
include_str!("../../docs/engine_distortion.md"),
),
Topic("Space & Time", include_str!("../../docs/engine_space.md")),
Topic("Words & Sounds", include_str!("../../docs/engine_words.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 {
DOCS.len()
DOCS.iter().filter(|e| matches!(e, Topic(_, _))).count()
}
fn get_topic(index: usize) -> Option<(&'static str, &'static str)> {
DOCS.iter()
.filter_map(|e| match e {
Topic(name, content) => Some((*name, *content)),
Section(_) => None,
})
.nth(index)
}
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let [topics_area, content_area] =
Layout::horizontal([Constraint::Length(18), Constraint::Fill(1)]).areas(area);
Layout::horizontal([Constraint::Length(24), Constraint::Fill(1)]).areas(area);
render_topics(frame, app, topics_area);
render_content(frame, app, content_area);
@@ -33,25 +161,77 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) {
fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get();
let visible_height = area.height.saturating_sub(2) as usize;
let total_items = DOCS.len();
// Find the visual index of the selected topic (including sections)
let selected_visual_idx = {
let mut visual = 0;
let mut topic_count = 0;
for entry in DOCS.iter() {
if let Topic(_, _) = entry {
if topic_count == app.ui.help_topic {
break;
}
topic_count += 1;
}
visual += 1;
}
visual
};
// Calculate scroll to keep selection visible (centered when possible)
let scroll = if selected_visual_idx < visible_height / 2 {
0
} else if selected_visual_idx > total_items.saturating_sub(visible_height / 2) {
total_items.saturating_sub(visible_height)
} else {
selected_visual_idx.saturating_sub(visible_height / 2)
};
// Count topics before the scroll offset to track topic_idx correctly
let mut topic_idx = DOCS
.iter()
.take(scroll)
.filter(|e| matches!(e, Topic(_, _)))
.count();
let items: Vec<ListItem> = DOCS
.iter()
.enumerate()
.map(|(i, (name, _))| {
let selected = i == app.ui.help_topic;
let style = if selected {
Style::new().fg(theme.dict.category_selected).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(theme.ui.text_primary)
};
let prefix = if selected { "> " } else { " " };
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)
}
Topic(name, _) => {
let selected = topic_idx == app.ui.help_topic;
let style = if selected {
Style::new()
.fg(theme.dict.category_selected)
.add_modifier(Modifier::BOLD)
} else {
Style::new().fg(theme.ui.text_primary)
};
let prefix = if selected { "> " } else { " " };
topic_idx += 1;
ListItem::new(format!("{prefix}{name}")).style(style)
}
})
.collect();
let focused = app.ui.help_focus == HelpFocus::Topics;
let border_color = if focused {
theme.dict.border_focused
} else {
theme.dict.border_normal
};
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(theme.dict.border_focused))
.border_style(Style::new().fg(border_color))
.title("Topics"),
);
frame.render_widget(list, area);
@@ -62,7 +242,9 @@ const BIG_TITLE_HEIGHT: u16 = 6;
fn render_content(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get();
let (_, md) = DOCS[app.ui.help_topic];
let Some((_, md)) = get_topic(app.ui.help_topic) else {
return;
};
let is_welcome = app.ui.help_topic == WELCOME_TOPIC;
let md_area = if is_welcome {
@@ -92,27 +274,9 @@ 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 search_bar_height: u16 = u16::from(has_search_bar);
let visible_height = md_area.height.saturating_sub(6 + search_bar_height) as usize;
let max_scroll = lines.len().saturating_sub(visible_height);
let scroll = app.ui.help_scroll().min(max_scroll);
let visible: Vec<RLine> = lines
.into_iter()
.skip(scroll)
.take(visible_height)
.map(|line| {
if has_query {
highlight_line(line, &query_lower)
} else {
line
}
})
.collect();
let content_area = if has_search_bar {
let [content, search] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(md_area);
@@ -122,17 +286,55 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
md_area
};
let para = Paragraph::new(visible)
// Calculate dimensions: 2 borders + 4 padding (2 left + 2 right)
let content_width = content_area.width.saturating_sub(6) as usize;
// 2 borders + 4 padding (2 top + 2 bottom)
let visible_height = content_area.height.saturating_sub(6) as usize;
// Calculate total wrapped line count for accurate max_scroll
let total_wrapped: usize = lines
.iter()
.map(|l| wrapped_line_count(l, content_width))
.sum();
let max_scroll = total_wrapped.saturating_sub(visible_height);
let scroll = app.ui.help_scroll().min(max_scroll);
let lines: Vec<RLine> = if has_query {
lines
.into_iter()
.map(|line| highlight_line(line, &query_lower))
.collect()
} else {
lines
};
let focused = app.ui.help_focus == HelpFocus::Content;
let border_color = if focused {
theme.dict.border_focused
} else {
theme.dict.border_normal
};
let para = Paragraph::new(lines)
.scroll((scroll as u16, 0))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(theme.ui.border))
.border_style(Style::new().fg(border_color))
.padding(Padding::new(2, 2, 2, 2)),
)
.wrap(Wrap { trim: false });
frame.render_widget(para, content_area);
}
fn wrapped_line_count(line: &RLine, width: usize) -> usize {
let char_count: usize = line.spans.iter().map(|s| s.content.chars().count()).sum();
if char_count == 0 || width == 0 {
1
} else {
char_count.div_ceil(width)
}
}
fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get();
let style = if app.ui.help_search_active {
@@ -156,7 +358,9 @@ fn highlight_line<'a>(line: RLine<'a>, query: &str) -> RLine<'a> {
}
let content = span.content.to_string();
let base_style = span.style;
let hl_style = base_style.bg(theme.search.match_bg).fg(theme.search.match_fg);
let hl_style = base_style
.bg(theme.search.match_bg)
.fg(theme.search.match_fg);
let mut start = 0;
let lower_bytes = lower.as_bytes();
let query_bytes = query.as_bytes();
@@ -185,7 +389,14 @@ fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
/// Find first line matching query across all topics. Returns (topic_index, line_index).
pub fn find_match(query: &str) -> Option<(usize, usize)> {
let query = query.to_lowercase();
for (topic_idx, (_, content)) in DOCS.iter().enumerate() {
for (topic_idx, (_, content)) in DOCS
.iter()
.filter_map(|e| match e {
Topic(name, content) => Some((*name, *content)),
Section(_) => None,
})
.enumerate()
{
for (line_idx, line) in content.lines().enumerate() {
if line.to_lowercase().contains(&query) {
return Some((topic_idx, line_idx));
@@ -194,173 +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_underscores(md: &str) -> String {
let mut out = String::with_capacity(md.len());
for line in md.lines() {
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 parse_markdown(md: &str) -> Vec<RLine<'static>> {
let processed = preprocess_underscores(md);
let text = minimad::Text::from(processed.as_str());
let mut lines = Vec::new();
let mut code_line_nr: usize = 0;
for line in text.lines {
match line {
Line::Normal(composite) if composite.style == CompositeStyle::Code => {
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) => {
code_line_nr = 0;
lines.push(composite_to_line(composite));
}
_ => {
lines.push(RLine::from(""));
}
}
}
lines
}
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 = match composite.style {
CompositeStyle::ListItem(_) => "",
CompositeStyle::Quote => "",
_ => "",
};
let mut spans: Vec<Span<'static>> = Vec::new();
if !prefix.is_empty() {
spans.push(Span::styled(prefix.to_string(), 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));
}
}

Some files were not shown because too many files have changed in this diff Show More