6 Commits

Author SHA1 Message Date
971d06ba31 Fix: UI/UX
Some checks failed
CI / check (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 1m21s
Deploy Website / deploy (push) Has been skipped
CI / check (macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / check (windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
2026-03-01 00:58:26 +01:00
2868e28ecc Fix: consume event on startup screen 2026-02-28 20:43:31 +01:00
64233647a6 Feat: make sure that the prelude is evaluated on startup 2026-02-28 20:30:23 +01:00
1ba946ebe6 [BREAKING] Feat: quotation is now using () 2026-02-28 20:25:59 +01:00
f88691c09c Feat: deleting step name when deleting the step 2026-02-28 12:33:14 +01:00
a2e941d385 Feat: less UI lag 2026-02-28 12:28:27 +01:00
47 changed files with 421 additions and 248 deletions

View File

@@ -1,6 +1,9 @@
# Uncomment to use local doux for development # Uncomment to use local doux for development
paths = ["/Users/bubo/doux"] paths = ["/Users/bubo/doux"]
[env]
MACOSX_DEPLOYMENT_TARGET = "12.0"
[alias] [alias]
xtask = "run --package xtask --release --" xtask = "run --package xtask --release --"

View File

@@ -7,6 +7,7 @@ on:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "12.0"
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
/.cache /.cache
*.prof *.prof
.DS_Store .DS_Store
releases/
# Local cargo overrides (doux path patch) # Local cargo overrides (doux path patch)
.cargo/config.local.toml .cargo/config.local.toml

View File

@@ -55,7 +55,7 @@ doux = { git = "https://github.com/sova-org/doux", features = ["native", "soundf
rusty_link = "0.4" rusty_link = "0.4"
ratatui = "0.30" ratatui = "0.30"
crossterm = "0.29" crossterm = "0.29"
cpal = { version = "0.17", features = ["jack"], optional = true } cpal = { version = "0.17", optional = true }
clap = { version = "4", features = ["derive"], optional = true } clap = { version = "4", features = ["derive"], optional = true }
rand = "0.8" rand = "0.8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
@@ -83,6 +83,9 @@ rustc-hash = { version = "2", optional = true }
image = { version = "0.25", default-features = false, features = ["png"], optional = true } image = { version = "0.25", default-features = false, features = ["png"], optional = true }
[target.'cfg(target_os = "linux")'.dependencies]
cpal = { version = "0.17", optional = true, features = ["jack"] }
[target.'cfg(windows)'.build-dependencies] [target.'cfg(windows)'.build-dependencies]
winres = "0.1" winres = "0.1"
@@ -109,3 +112,4 @@ icon = ["assets/Cagire.icns", "assets/Cagire.ico", "assets/Cagire.png"]
copyright = "Copyright (c) 2025 Raphaël Forment" copyright = "Copyright (c) 2025 Raphaël Forment"
category = "Music" category = "Music"
short_description = "Forth-based music sequencer" short_description = "Forth-based music sequencer"
minimum_system_version = "12.0"

View File

@@ -1,19 +1,18 @@
Cagire - A Forth-based music sequencer # Cagire - A Forth-based music sequencer
Made by BuboBubo and his friends
====================================== ## Installation
Installation
------------
Drag Cagire.app into the Applications folder. Drag Cagire.app into the Applications folder.
Unquarantine ## Unquarantine
------------
Since this app is not signed with an Apple Developer certificate, Since this app is not signed with an Apple Developer certificate,
macOS will block it from running. To fix this, open Terminal and run: macOS will block it from running. Thanks Apple! To fix this, open
Terminal and run:
xattr -cr /Applications/Cagire.app xattr -cr /Applications/Cagire.app
Support ## Support
-------
If you enjoy Cagire, consider supporting development: If you enjoy this software, consider supporting development:
https://ko-fi.com/raphaelbubo https://ko-fi.com/raphaelbubo

View File

@@ -31,7 +31,7 @@ fn tokenize(input: &str) -> Vec<Token> {
continue; continue;
} }
if c == '(' || c == ')' { if c == '{' || c == '}' {
chars.next(); chars.next();
continue; continue;
} }
@@ -133,7 +133,7 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> {
Token::Str(s, span) => ops.push(Op::PushStr(Arc::from(s.as_str()), Some(*span))), Token::Str(s, span) => ops.push(Op::PushStr(Arc::from(s.as_str()), Some(*span))),
Token::Word(w, span) => { Token::Word(w, span) => {
let word = w.as_str(); let word = w.as_str();
if word == "{" { if word == "(" {
let (quote_ops, consumed, end_span) = let (quote_ops, consumed, end_span) =
compile_quotation(&tokens[i + 1..], dict)?; compile_quotation(&tokens[i + 1..], dict)?;
i += consumed; i += consumed;
@@ -142,8 +142,8 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> {
end: end_span.end, end: end_span.end,
}; };
ops.push(Op::Quotation(Arc::from(quote_ops), Some(body_span))); ops.push(Op::Quotation(Arc::from(quote_ops), Some(body_span)));
} else if word == "}" { } else if word == ")" {
return Err("unexpected }".into()); return Err("unexpected )".into());
} else if word == "[" { } else if word == "[" {
let (bracket_ops, consumed, end_span) = let (bracket_ops, consumed, end_span) =
compile_bracket(&tokens[i + 1..], dict)?; compile_bracket(&tokens[i + 1..], dict)?;
@@ -203,8 +203,8 @@ fn compile_quotation(
for (i, tok) in tokens.iter().enumerate() { for (i, tok) in tokens.iter().enumerate() {
if let Token::Word(w, _) = tok { if let Token::Word(w, _) = tok {
match w.as_str() { match w.as_str() {
"{" => depth += 1, "(" => depth += 1,
"}" => { ")" => {
depth -= 1; depth -= 1;
if depth == 0 { if depth == 0 {
end_idx = Some(i); end_idx = Some(i);
@@ -216,7 +216,7 @@ fn compile_quotation(
} }
} }
let end_idx = end_idx.ok_or("missing }")?; let end_idx = end_idx.ok_or("missing )")?;
let end_span = match &tokens[end_idx] { let end_span = match &tokens[end_idx] {
Token::Word(_, span) => *span, Token::Word(_, span) => *span,
_ => unreachable!(), _ => unreachable!(),

View File

@@ -502,7 +502,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Logic", category: "Logic",
stack: "(true-quot false-quot bool --)", stack: "(true-quot false-quot bool --)",
desc: "Execute true-quot if true, else false-quot", desc: "Execute true-quot if true, else false-quot",
example: "{ 1 } { 2 } coin ifelse", example: "( 1 ) ( 2 ) coin ifelse",
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },
@@ -512,7 +512,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Logic", category: "Logic",
stack: "(..quots n --)", stack: "(..quots n --)",
desc: "Execute nth quotation (0-indexed)", desc: "Execute nth quotation (0-indexed)",
example: "{ 1 } { 2 } { 3 } 2 select => 3", example: "( 1 ) ( 2 ) ( 3 ) 2 select => 3",
compile: Simple, compile: Simple,
varargs: true, varargs: true,
}, },
@@ -522,7 +522,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Logic", category: "Logic",
stack: "(quot bool --)", stack: "(quot bool --)",
desc: "Execute quotation if true", desc: "Execute quotation if true",
example: "{ 2 distort } 0.5 chance ?", example: "( 2 distort ) 0.5 chance ?",
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },
@@ -532,7 +532,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Logic", category: "Logic",
stack: "(quot bool --)", stack: "(quot bool --)",
desc: "Execute quotation if false", desc: "Execute quotation if false",
example: "{ 1 distort } 0.5 chance !?", example: "( 1 distort ) 0.5 chance !?",
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },
@@ -542,7 +542,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Logic", category: "Logic",
stack: "(quot --)", stack: "(quot --)",
desc: "Execute quotation unconditionally", desc: "Execute quotation unconditionally",
example: "{ 2 * } apply", example: "( 2 * ) apply",
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },
@@ -553,7 +553,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Control", category: "Control",
stack: "(n quot --)", stack: "(n quot --)",
desc: "Execute quotation n times, @i holds current index", desc: "Execute quotation n times, @i holds current index",
example: "4 { @i . } times => 0 1 2 3", example: "4 ( @i . ) times => 0 1 2 3",
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },

View File

@@ -60,7 +60,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Probability", category: "Probability",
stack: "(quot prob --)", stack: "(quot prob --)",
desc: "Execute quotation with probability (0.0-1.0)", desc: "Execute quotation with probability (0.0-1.0)",
example: "{ 2 distort } 0.75 chance", example: "( 2 distort ) 0.75 chance",
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },
@@ -70,7 +70,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Probability", category: "Probability",
stack: "(quot pct --)", stack: "(quot pct --)",
desc: "Execute quotation with probability (0-100)", desc: "Execute quotation with probability (0-100)",
example: "{ 2 distort } 75 prob", example: "( 2 distort ) 75 prob",
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },
@@ -150,7 +150,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Probability", category: "Probability",
stack: "(quot --)", stack: "(quot --)",
desc: "Always execute quotation", desc: "Always execute quotation",
example: "{ 2 distort } always", example: "( 2 distort ) always",
compile: Probability(1.0), compile: Probability(1.0),
varargs: false, varargs: false,
}, },
@@ -160,7 +160,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Probability", category: "Probability",
stack: "(quot --)", stack: "(quot --)",
desc: "Never execute quotation", desc: "Never execute quotation",
example: "{ 2 distort } never", example: "( 2 distort ) never",
compile: Probability(0.0), compile: Probability(0.0),
varargs: false, varargs: false,
}, },
@@ -170,7 +170,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Probability", category: "Probability",
stack: "(quot --)", stack: "(quot --)",
desc: "Execute quotation 75% of the time", desc: "Execute quotation 75% of the time",
example: "{ 2 distort } often", example: "( 2 distort ) often",
compile: Probability(0.75), compile: Probability(0.75),
varargs: false, varargs: false,
}, },
@@ -180,7 +180,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Probability", category: "Probability",
stack: "(quot --)", stack: "(quot --)",
desc: "Execute quotation 50% of the time", desc: "Execute quotation 50% of the time",
example: "{ 2 distort } sometimes", example: "( 2 distort ) sometimes",
compile: Probability(0.5), compile: Probability(0.5),
varargs: false, varargs: false,
}, },
@@ -190,7 +190,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Probability", category: "Probability",
stack: "(quot --)", stack: "(quot --)",
desc: "Execute quotation 25% of the time", desc: "Execute quotation 25% of the time",
example: "{ 2 distort } rarely", example: "( 2 distort ) rarely",
compile: Probability(0.25), compile: Probability(0.25),
varargs: false, varargs: false,
}, },
@@ -200,7 +200,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Probability", category: "Probability",
stack: "(quot --)", stack: "(quot --)",
desc: "Execute quotation 10% of the time", desc: "Execute quotation 10% of the time",
example: "{ 2 distort } almostNever", example: "( 2 distort ) almostNever",
compile: Probability(0.1), compile: Probability(0.1),
varargs: false, varargs: false,
}, },
@@ -210,7 +210,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Probability", category: "Probability",
stack: "(quot --)", stack: "(quot --)",
desc: "Execute quotation 90% of the time", desc: "Execute quotation 90% of the time",
example: "{ 2 distort } almostAlways", example: "( 2 distort ) almostAlways",
compile: Probability(0.9), compile: Probability(0.9),
varargs: false, varargs: false,
}, },
@@ -221,7 +221,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Time", category: "Time",
stack: "(quot n --)", stack: "(quot n --)",
desc: "Execute quotation every nth iteration", desc: "Execute quotation every nth iteration",
example: "{ 2 distort } 4 every", example: "( 2 distort ) 4 every",
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },
@@ -231,7 +231,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Time", category: "Time",
stack: "(quot n --)", stack: "(quot n --)",
desc: "Execute quotation on all iterations except every nth", desc: "Execute quotation on all iterations except every nth",
example: "{ 2 distort } 4 except", example: "( 2 distort ) 4 except",
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },
@@ -241,7 +241,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Time", category: "Time",
stack: "(quot n offset --)", stack: "(quot n offset --)",
desc: "Execute quotation every nth iteration with phase offset", desc: "Execute quotation every nth iteration with phase offset",
example: "{ snare } 4 2 every+ => fires at iter 2, 6, 10...", example: "( snare ) 4 2 every+ => fires at iter 2, 6, 10...",
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },
@@ -251,7 +251,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Time", category: "Time",
stack: "(quot n offset --)", stack: "(quot n offset --)",
desc: "Skip quotation every nth iteration with phase offset", desc: "Skip quotation every nth iteration with phase offset",
example: "{ snare } 4 2 except+ => skips at iter 2, 6, 10...", example: "( snare ) 4 2 except+ => skips at iter 2, 6, 10...",
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },
@@ -261,7 +261,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Time", category: "Time",
stack: "(quot k n --)", stack: "(quot k n --)",
desc: "Execute quotation using Euclidean distribution over step runs", desc: "Execute quotation using Euclidean distribution over step runs",
example: "{ 2 distort } 3 8 bjork", example: "( 2 distort ) 3 8 bjork",
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },
@@ -271,7 +271,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Time", category: "Time",
stack: "(quot k n --)", stack: "(quot k n --)",
desc: "Execute quotation using Euclidean distribution over pattern iterations", desc: "Execute quotation using Euclidean distribution over pattern iterations",
example: "{ 2 distort } 3 8 pbjork", example: "( 2 distort ) 3 8 pbjork",
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },
@@ -456,7 +456,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Desktop", category: "Desktop",
stack: "(-- bool)", stack: "(-- bool)",
desc: "1 when mouse button held, 0 otherwise", desc: "1 when mouse button held, 0 otherwise",
example: "mdown { \"crash\" s . } ?", example: "mdown ( \"crash\" s . ) ?",
compile: Context("mdown"), compile: Context("mdown"),
varargs: false, varargs: false,
}, },
@@ -487,7 +487,7 @@ pub(super) const WORDS: &[Word] = &[
category: "Generator", category: "Generator",
stack: "(quot n -- results...)", stack: "(quot n -- results...)",
desc: "Execute quotation n times, push all results", desc: "Execute quotation n times, push all results",
example: "{ 1 6 rand } 4 gen => 4 random values", example: "( 1 6 rand ) 4 gen => 4 random values",
compile: Simple, compile: Simple,
varargs: true, varargs: true,
}, },

View File

@@ -4,7 +4,7 @@ use crate::theme;
use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame; use ratatui::Frame;
/// Node type in the sample tree. /// Node type in the sample tree.
@@ -116,13 +116,13 @@ impl<'a> SampleBrowser<'a> {
fn render_tree(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) { fn render_tree(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) {
let height = area.height as usize; let height = area.height as usize;
if self.entries.is_empty() { if self.entries.is_empty() {
let msg = if self.search_query.is_empty() { if self.search_query.is_empty() {
"No samples loaded" self.render_empty_guide(frame, area, colors);
} else { } else {
"No matches" let line =
}; Line::from(Span::styled("No matches", Style::new().fg(colors.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);
frame.render_widget(Paragraph::new(vec![line]), area); }
return; return;
} }
@@ -179,4 +179,47 @@ impl<'a> SampleBrowser<'a> {
frame.render_widget(Paragraph::new(lines), area); frame.render_widget(Paragraph::new(lines), area);
} }
fn render_empty_guide(&self, frame: &mut Frame, area: Rect, colors: &theme::ThemeColors) {
let muted = Style::new().fg(colors.browser.empty_text);
let heading = Style::new().fg(colors.ui.text_primary);
let key = Style::new().fg(colors.hint.key);
let desc = Style::new().fg(colors.hint.text);
let code = Style::new().fg(colors.ui.accent);
let lines = vec![
Line::from(Span::styled(" No samples loaded.", muted)),
Line::from(""),
Line::from(Span::styled(" Load from the Engine page:", heading)),
Line::from(""),
Line::from(vec![
Span::styled(" F6 ", key),
Span::styled("Go to Engine page", desc),
]),
Line::from(vec![
Span::styled(" A ", key),
Span::styled("Add a sample folder", desc),
]),
Line::from(""),
Line::from(Span::styled(" Organize samples like this:", heading)),
Line::from(""),
Line::from(Span::styled(" samples/", code)),
Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} kick/", code)),
Line::from(Span::styled(" \u{2502} \u{2514}\u{2500}\u{2500} kick.wav", code)),
Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} snare/", code)),
Line::from(Span::styled(" \u{2502} \u{2514}\u{2500}\u{2500} snare.wav", code)),
Line::from(Span::styled(" \u{2514}\u{2500}\u{2500} hats/", code)),
Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} closed.wav", code)),
Line::from(Span::styled(" \u{251C}\u{2500}\u{2500} open.wav", code)),
Line::from(Span::styled(" \u{2514}\u{2500}\u{2500} pedal.wav", code)),
Line::from(""),
Line::from(Span::styled(" Folders become Forth words:", heading)),
Line::from(""),
Line::from(Span::styled(" kick sound .", code)),
Line::from(Span::styled(" hats sound 2 n .", code)),
Line::from(Span::styled(" snare sound 0.5 speed .", code)),
];
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
}
} }

View File

@@ -7,11 +7,11 @@
"steps": [ "steps": [
{ {
"i": 0, "i": 0,
"script": "0 8 12 rand ..\nc3 c4 g3 g2 4 pcycle key!\n0 1 2 choose 2\n6 12 rand pentatonic\n{ inv } rarely\n{ inv } sometimes arp note\ngrain sound 2 8 rand decay \n2 vib 0.125 2 / vibmod\n0.01 1.0 exprand pan\n2 release\n0.8 verb 1.0 verbdiff\n0.2 chorus\n1 morph\n0.0 1.0 rand \n0.0 1.0 rand timbre\n0.5 gain\n0.8 sustain\n2 8 rand release\n." "script": "0 8 12 rand ..\nc3 c4 g3 g2 4 pcycle key!\n0 1 2 choose 2\n6 12 rand pentatonic\n( inv ) rarely\n( inv ) sometimes arp note\ngrain sound 2 8 rand decay \n2 vib 0.125 2 / vibmod\n0.01 1.0 exprand pan\n2 release\n0.8 verb 1.0 verbdiff\n0.2 chorus\n1 morph\n0.0 1.0 rand \n0.0 1.0 rand timbre\n0.5 gain\n0.8 sustain\n2 8 rand release\n."
}, },
{ {
"i": 4, "i": 4,
"script": "0 12 20 rand ..\nc3 c4 g3 g2 4 pcycle key!\n0 1 2 choose 2\n6 12 rand pentatonic\n{ inv } rarely\n{ inv } sometimes arp note\ngrain sound 2 8 rand decay \n2 vib 0.125 2 / vibmod\n0.01 1.0 exprand pan\n10 16 rand release\n0.8 verb 1.0 verbdiff\n0.2 chorus\n1 morph\n0.0 1.0 rand 0.0 1.0 rand timbre\n0.5 gain\n{ . } 2 every" "script": "0 12 20 rand ..\nc3 c4 g3 g2 4 pcycle key!\n0 1 2 choose 2\n6 12 rand pentatonic\n( inv ) rarely\n( inv ) sometimes arp note\ngrain sound 2 8 rand decay \n2 vib 0.125 2 / vibmod\n0.01 1.0 exprand pan\n10 16 rand release\n0.8 verb 1.0 verbdiff\n0.2 chorus\n1 morph\n0.0 1.0 rand 0.0 1.0 rand timbre\n0.5 gain\n( . ) 2 every"
} }
], ],
"length": 16, "length": 16,

View File

@@ -20,15 +20,17 @@ The engine scans these directories and builds a registry of available samples. S
``` ```
samples/ samples/
├── kick.wav → "kick" ├── kick/ → "kick"
├── snare.wav → "snare" │ └── kick.wav
├── snare/ → "snare"
│ └── snare.wav
└── hats/ └── hats/
├── closed.wav → "hats" n 0 ├── closed.wav → "hats" n 0
├── open.wav → "hats" n 1 ├── open.wav → "hats" n 1
└── pedal.wav → "hats" n 2 └── 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`. Folders at the root of your sample directory become sample banks named after the folder. Each file within a folder gets an index. Files are sorted alphabetically and assigned indices starting from `0`.
## Playing Samples ## Playing Samples

View File

@@ -54,7 +54,7 @@ Four basic types of values can live on the stack:
- **Integers**: `42`, `-7`, `0` - **Integers**: `42`, `-7`, `0`
- **Floats**: `0.5`, `3.14`, `-1.0` - **Floats**: `0.5`, `3.14`, `-1.0`
- **Strings**: `"kick"`, `"hello"` - **Strings**: `"kick"`, `"hello"`
- **Quotations**: `{ dup + }` (code as data) - **Quotations**: `( dup + )` (code as data)
Floats can omit the leading zero: `.25` is the same as `0.25`, and `-.5` is `-0.5`. Floats can omit the leading zero: `.25` is the same as `0.25`, and `-.5` is `-0.5`.

View File

@@ -29,22 +29,22 @@ These are compiled directly into branch instructions. For that reason, these wor
When you already have a quotation, `?` executes it if the condition is truthy: When you already have a quotation, `?` executes it if the condition is truthy:
```forth ```forth
{ 0.4 verb } coin ? ( 0.4 verb ) coin ?
saw s c4 note 0.5 gain . ;; reverb on half the hits saw s c4 note 0.5 gain . ;; reverb on half the hits
``` ```
`!?` is the opposite — executes when falsy: `!?` is the opposite — executes when falsy:
```forth ```forth
{ 0.2 gain } coin !? ( 0.2 gain ) coin !?
saw s c4 note . ;; quiet on half the hits saw s c4 note . ;; quiet on half the hits
``` ```
These pair well with `chance`, `prob`, and the other probability words: These pair well with `chance`, `prob`, and the other probability words:
```forth ```forth
{ 0.5 verb } 0.3 chance ? ;; occasional reverb wash ( 0.5 verb ) 0.3 chance ? ;; occasional reverb wash
{ 12 + } fill ? ;; octave up during fills ( 12 + ) fill ? ;; octave up during fills
``` ```
## ifelse ## ifelse
@@ -52,14 +52,14 @@ These pair well with `chance`, `prob`, and the other probability words:
Two quotations, one condition. The true branch comes first: Two quotations, one condition. The true branch comes first:
```forth ```forth
{ c3 note } { c4 note } coin ifelse ( c3 note ) ( c4 note ) coin ifelse
saw s 0.6 gain . ;; bass or lead, coin flip saw s 0.6 gain . ;; bass or lead, coin flip
``` ```
Reads naturally: "c3 or c4, depending on the coin." Reads naturally: "c3 or c4, depending on the coin."
```forth ```forth
{ 0.8 gain } { 0.3 gain } fill ifelse ( 0.8 gain ) ( 0.3 gain ) fill ifelse
tri s c4 note 0.2 decay . ;; loud during fills, quiet otherwise tri s c4 note 0.2 decay . ;; loud during fills, quiet otherwise
``` ```
@@ -68,7 +68,7 @@ tri s c4 note 0.2 decay . ;; loud during fills, quiet otherwise
Choose the nth option from a list of quotations: Choose the nth option from a list of quotations:
```forth ```forth
{ c4 } { e4 } { g4 } { b4 } iter 4 mod select ( c4 ) ( e4 ) ( g4 ) ( b4 ) iter 4 mod select
note sine s 0.5 decay . note sine s 0.5 decay .
``` ```
@@ -79,7 +79,7 @@ Four notes cycling through a major seventh chord, one per pattern iteration. The
When you have a quotation and want to execute it unconditionally, use `apply`: When you have a quotation and want to execute it unconditionally, use `apply`:
```forth ```forth
{ dup + } apply ;; doubles the top value ( dup + ) apply ;; doubles the top value
``` ```
This is simpler than `?` when there is no condition to check. It pops the quotation and runs it. This is simpler than `?` when there is no condition to check. It pops the quotation and runs it.
@@ -115,14 +115,14 @@ saw s c4 note .
Repeat a quotation n times. The variable `@i` is automatically set to the current iteration index (starting from 0): Repeat a quotation n times. The variable `@i` is automatically set to the current iteration index (starting from 0):
```forth ```forth
3 { c4 @i 4 * + note } times 3 ( c4 @i 4 * + note ) times
sine s 0.4 gain 0.5 verb . ;; c4, e4, g#4 a chord sine s 0.4 gain 0.5 verb . ;; c4, e4, g#4 a chord
``` ```
Subdivide with `at`: Subdivide with `at`:
```forth ```forth
4 { @i 4 / at sine s c4 note 0.3 gain . } times 4 ( @i 4 / at sine s c4 note 0.3 gain . ) times
``` ```
Four evenly spaced notes within the step. Four evenly spaced notes within the step.
@@ -130,11 +130,11 @@ Four evenly spaced notes within the step.
Vary intensity per iteration: Vary intensity per iteration:
```forth ```forth
8 { 8 (
@i 8 / at @i 8 / at
@i 4 mod 0 = if 0.7 else 0.2 then gain @i 4 mod 0 = if 0.7 else 0.2 then gain
tri s c5 note 0.1 decay . tri s c5 note 0.1 decay .
} times ) times
``` ```
Eight notes per step. Every fourth one louder. Eight notes per step. Every fourth one louder.

View File

@@ -22,10 +22,10 @@ Everything after `;;` until the end of the line is ignored.
Classic Forth has no quotations. Code is not a value you can pass around. Classic Forth has no quotations. Code is not a value you can pass around.
Cagire has first-class quotations using curly braces: Cagire has first-class quotations using parentheses:
```forth ```forth
{ dup + } ( 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. 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.
@@ -41,14 +41,14 @@ x 0 > IF 1 ELSE -1 THEN
Cagire supports this syntax but also provides quotation-based conditionals: Cagire supports this syntax but also provides quotation-based conditionals:
```forth ```forth
{ 1 } { -1 } x 0 > ifelse ( 1 ) ( -1 ) x 0 > ifelse
``` ```
The words `?` and `!?` execute a quotation based on a condition: The words `?` and `!?` execute a quotation based on a condition:
```forth ```forth
{ "kick" s . } coin ? ;; execute if coin is 1 ( "kick" s . ) coin ? ;; execute if coin is 1
{ "snare" s . } coin !? ;; execute if coin is 0 ( "snare" s . ) coin !? ;; execute if coin is 0
``` ```
## Strings ## Strings
@@ -116,21 +116,21 @@ Classic Forth has `DO ... LOOP`:
Cagire uses a quotation-based loop with `times`: Cagire uses a quotation-based loop with `times`:
```forth ```forth
4 { @i . } times ;; prints 0 1 2 3 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. The loop counter is stored in the variable `i`, accessed with `@i`. This fits Cagire's style where control flow uses quotations.
```forth ```forth
4 { @i 4 / at hat s . } times ;; hat at 0, 0.25, 0.5, 0.75 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 4 ( c4 @i + note sine s . ) times ;; ascending notes
``` ```
For generating sequences without side effects, use `..` or `gen`: For generating sequences without side effects, use `..` or `gen`:
```forth ```forth
1 5 .. ;; pushes 1 2 3 4 5 1 5 .. ;; pushes 1 2 3 4 5
{ dup * } 4 gen ;; pushes 0 1 4 9 (squares) ( dup * ) 4 gen ;; pushes 0 1 4 9 (squares)
``` ```
## The Command Register ## The Command Register
@@ -167,11 +167,11 @@ These have no equivalent in classic Forth. They connect your script to the seque
Classic Forth is deterministic. Cagire has built-in randomness: Classic Forth is deterministic. Cagire has built-in randomness:
```forth ```forth
{ "snare" s . } 50 prob ;; 50% chance ( "snare" s . ) 50 prob ;; 50% chance
{ "clap" s . } 0.25 chance ;; 25% chance ( "clap" s . ) 0.25 chance ;; 25% chance
{ "hat" s . } often ;; 75% chance ( "hat" s . ) often ;; 75% chance
{ "rim" s . } sometimes ;; 50% chance ( "rim" s . ) sometimes ;; 50% chance
{ "tom" s . } rarely ;; 25% chance ( "tom" s . ) rarely ;; 25% chance
``` ```
These words take a quotation and execute it probabilistically. These words take a quotation and execute it probabilistically.
@@ -181,9 +181,9 @@ These words take a quotation and execute it probabilistically.
Execute a quotation on specific iterations: Execute a quotation on specific iterations:
```forth ```forth
{ "snare" s . } 4 every ;; every 4th pattern iteration ( "snare" s . ) 4 every ;; every 4th pattern iteration
{ "hat" s . } 3 8 bjork ;; Euclidean: 3 hits across 8 step runs ( "hat" s . ) 3 8 bjork ;; Euclidean: 3 hits across 8 step runs
{ "hat" s . } 5 8 pbjork ;; Euclidean: 5 hits across 8 pattern iterations ( "hat" s . ) 5 8 pbjork ;; Euclidean: 5 hits across 8 pattern iterations
``` ```
`every` checks the pattern iteration count. On iteration 0, 4, 8, 12... the quotation runs. On all other iterations it is skipped. `every` checks the pattern iteration count. On iteration 0, 4, 8, 12... the quotation runs. On all other iterations it is skipped.
@@ -203,7 +203,7 @@ Each time the step runs, a different note is selected. The `3` tells `cycle` how
You can also use quotations if you need to execute code: You can also use quotations if you need to execute code:
```forth ```forth
{ c4 note } { e4 note } { g4 note } 3 cycle ( 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. When the selected value is a quotation, it gets executed. When it is a plain value, it gets pushed onto the stack.

View File

@@ -75,7 +75,7 @@ Euclidean distribution via `euclid`:
Random timing via `gen`: Random timing via `gen`:
```forth ```forth
{ 0.0 1.0 rand } 4 gen at hat s . ;; 4 hats at random positions ( 0.0 1.0 rand ) 4 gen at hat s . ;; 4 hats at random positions
``` ```
Geometric spacing via `geom..`: Geometric spacing via `geom..`:
@@ -89,10 +89,10 @@ Geometric spacing via `geom..`:
Wrap `at` expressions in quotations for conditional timing: Wrap `at` expressions in quotations for conditional timing:
```forth ```forth
{ 0 0.25 0.5 0.75 at } 2 every ;; 16th-note hats every other bar ( 0 0.25 0.5 0.75 at ) 2 every ;; 16th-note hats every other bar
hat s . hat s .
{ 0 0.5 at } 0.5 chance ;; 50% chance of double-hit ( 0 0.5 at ) 0.5 chance ;; 50% chance of double-hit
kick s . kick s .
``` ```

View File

@@ -40,15 +40,15 @@ That gives you 110, 220, 440, 880, 1760 (reversed), ready to feed into `freq`.
`gen` executes a quotation n times and collects all results. The quotation must push exactly one value per call: `gen` executes a quotation n times and collects all results. The quotation must push exactly one value per call:
```forth ```forth
{ 1 6 rand } 4 gen ;; 4 random values between 1 and 6 ( 1 6 rand ) 4 gen ;; 4 random values between 1 and 6
{ coin } 8 gen ;; 8 random 0s and 1s ( coin ) 8 gen ;; 8 random 0s and 1s
``` ```
Contrast with `times`, which executes for side effects and does not collect. `times` sets `@i` to the current index: Contrast with `times`, which executes for side effects and does not collect. `times` sets `@i` to the current index:
```forth ```forth
4 { @i } times ;; 0 1 2 3 (pushes @i each iteration) 4 ( @i ) times ;; 0 1 2 3 (pushes @i each iteration)
4 { @i 60 + note sine s . } times ;; plays 4 notes, collects nothing 4 ( @i 60 + note sine s . ) times ;; plays 4 notes, collects nothing
``` ```
The distinction: `gen` is for building data. `times` is for doing things. The distinction: `gen` is for building data. `times` is for doing things.
@@ -109,7 +109,7 @@ c4 e4 g4 b4 4 shuffle ;; random permutation each time
Useful for computing averages or accumulating values: Useful for computing averages or accumulating values:
```forth ```forth
{ 1 6 rand } 4 gen 4 sum ;; sum of 4 dice rolls ( 1 6 rand ) 4 gen 4 sum ;; sum of 4 dice rolls
``` ```
## Replication ## Replication

View File

@@ -263,7 +263,7 @@ c4 mtof freq sine s .
A chord progression cycling every pattern iteration: A chord progression cycling every pattern iteration:
```forth ```forth
{ c3 maj7 } { f3 maj7 } { g3 dom7 } { c3 maj7 } 4 pcycle ( c3 maj7 ) ( f3 maj7 ) ( g3 dom7 ) ( c3 maj7 ) 4 pcycle
note sine s . note sine s .
``` ```
@@ -290,7 +290,7 @@ Chord voicings with random inversion:
```forth ```forth
e3 min9 e3 min9
{ } { 1 oct } 2 choose ( ) ( 1 oct ) 2 choose
note modal s . note modal s .
``` ```

View File

@@ -31,8 +31,8 @@ These are useful for parameters where perception is logarithmic, like frequency
The probability words take a quotation and execute it with some chance. `chance` takes a float from 0.0 to 1.0, `prob` takes a percentage from 0 to 100: The probability words take a quotation and execute it with some chance. `chance` takes a float from 0.0 to 1.0, `prob` takes a percentage from 0 to 100:
```forth ```forth
{ hat s . } 0.25 chance ;; 25% chance ( hat s . ) 0.25 chance ;; 25% chance
{ hat s . } 75 prob ;; 75% chance ( hat s . ) 75 prob ;; 75% chance
``` ```
Named probability words save you from remembering numbers: Named probability words save you from remembering numbers:
@@ -48,9 +48,9 @@ Named probability words save you from remembering numbers:
| `never` | 0% | | `never` | 0% |
```forth ```forth
{ hat s . } often ;; 75% ( hat s . ) often ;; 75%
{ snare s . } sometimes ;; 50% ( snare s . ) sometimes ;; 50%
{ clap s . } rarely ;; 25% ( clap s . ) rarely ;; 25%
``` ```
`always` and `never` are useful when you want to temporarily mute or unmute a voice without deleting code. Change `sometimes` to `never` to silence it, `always` to bring it back. `always` and `never` are useful when you want to temporarily mute or unmute a voice without deleting code. Change `sometimes` to `never` to silence it, `always` to bring it back.
@@ -58,8 +58,8 @@ Named probability words save you from remembering numbers:
Use `?` and `!?` with `coin` for quick coin-flip decisions: Use `?` and `!?` with `coin` for quick coin-flip decisions:
```forth ```forth
{ hat s . } coin ? ;; execute if coin is 1 ( hat s . ) coin ? ;; execute if coin is 1
{ rim s . } coin !? ;; execute if coin is 0 ( rim s . ) coin !? ;; execute if coin is 0
``` ```
## Selection ## Selection
@@ -74,7 +74,7 @@ kick snare hat 3 choose s . ;; random drum hit
When a chosen item is a quotation, it gets executed: When a chosen item is a quotation, it gets executed:
```forth ```forth
{ 0.1 decay } { 0.5 decay } { 0.9 decay } 3 choose ( 0.1 decay ) ( 0.5 decay ) ( 0.9 decay ) 3 choose
sine s . sine s .
``` ```
@@ -115,7 +115,7 @@ The difference matters when patterns have different lengths. `cycle` counts per-
Quotations work here too: Quotations work here too:
```forth ```forth
{ c4 note } { e4 note } { g4 note } 3 cycle ( c4 note ) ( e4 note ) ( g4 note ) 3 cycle
sine s . sine s .
``` ```
@@ -130,20 +130,20 @@ sine s .
`every` runs a quotation once every n pattern iterations: `every` runs a quotation once every n pattern iterations:
```forth ```forth
{ crash s . } 4 every ;; crash cymbal every 4th iteration ( crash s . ) 4 every ;; crash cymbal every 4th iteration
``` ```
`except` is the inverse -- it runs a quotation on all iterations *except* every nth: `except` is the inverse -- it runs a quotation on all iterations *except* every nth:
```forth ```forth
{ 2 distort } 4 except ;; distort on all iterations except every 4th ( 2 distort ) 4 except ;; distort on all iterations except every 4th
``` ```
`bjork` and `pbjork` use Bjorklund's algorithm to distribute k hits across n positions as evenly as possible. Classic Euclidean rhythms: `bjork` and `pbjork` use Bjorklund's algorithm to distribute k hits across n positions as evenly as possible. Classic Euclidean rhythms:
```forth ```forth
{ hat s . } 3 8 bjork ;; tresillo: x..x..x. (by step runs) ( hat s . ) 3 8 bjork ;; tresillo: x..x..x. (by step runs)
{ hat s . } 5 8 pbjork ;; cinquillo: x.xx.xx. (by pattern iterations) ( hat s . ) 5 8 pbjork ;; cinquillo: x.xx.xx. (by pattern iterations)
``` ```
`bjork` counts by step runs (how many times this particular step has played). `pbjork` counts by pattern iterations. Some classic patterns: `bjork` counts by step runs (how many times this particular step has played). `pbjork` counts by pattern iterations. Some classic patterns:
@@ -172,7 +172,7 @@ The real power comes from mixing techniques. A hi-hat pattern with ghost notes:
```forth ```forth
hat s hat s
{ 0.3 0.6 rand gain } { 0.8 gain } 2 cycle ( 0.3 0.6 rand gain ) ( 0.8 gain ) 2 cycle
. .
``` ```
@@ -181,18 +181,18 @@ Full volume on even runs, random quiet on odd runs.
A bass line that changes every 4 bars: A bass line that changes every 4 bars:
```forth ```forth
{ c2 note } { e2 note } { g2 note } { a2 note } 4 pcycle ( c2 note ) ( e2 note ) ( g2 note ) ( a2 note ) 4 pcycle
{ 0.5 decay } often ( 0.5 decay ) often
sine s . sine s .
``` ```
Layered percussion with different densities: Layered percussion with different densities:
```forth ```forth
{ kick s . } always ( kick s . ) always
{ snare s . } 2 every ( snare s . ) 2 every
{ hat s . } 5 8 bjork ( hat s . ) 5 8 bjork
{ rim s . } rarely ( rim s . ) rarely
``` ```
A melodic step with weighted note selection and random timbre: A melodic step with weighted note selection and random timbre:

View File

@@ -53,7 +53,7 @@ Reset on some condition:
```forth ```forth
@n 1 + !n @n 1 + !n
{ 0 !n } @n 16 > ? ;; reset after 16 ( 0 !n ) @n 16 > ? ;; reset after 16
``` ```
## When Changes Take Effect ## When Changes Take Effect

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleDisplayName</key>
<string>Cagire</string>
<key>CFBundleExecutable</key>
<string>cagire-desktop</string>
<key>CFBundleIconFile</key>
<string>Cagire.icns</string>
<key>CFBundleIdentifier</key>
<string>com.sova.cagire</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Cagire</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.9</string>
<key>CFBundleVersion</key>
<string>20260228.204543</string>
<key>CSResourcesFileMapped</key>
<true/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.music</string>
<key>LSRequiresCarbon</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright (c) 2025 Raphaël Forment</string>
</dict>
</plist>

Binary file not shown.

BIN
releases/Cagire-aarch64.dmg Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -1,11 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
export MACOSX_DEPLOYMENT_TARGET="12.0"
cd "$(git rev-parse --show-toplevel)" cd "$(git rev-parse --show-toplevel)"
PLUGIN_NAME="cagire-plugins" PLUGIN_NAME="cagire-plugins"
LIB_NAME="cagire_plugins" # cargo converts hyphens to underscores LIB_NAME="cagire_plugins" # cargo converts hyphens to underscores
OUT="target/releases" OUT="releases"
PLATFORMS=( PLATFORMS=(
"aarch64-apple-darwin" "aarch64-apple-darwin"
@@ -312,13 +314,16 @@ copy_artifacts() {
# macOS .app bundle # macOS .app bundle
if [[ "$os" == "macos" ]]; then if [[ "$os" == "macos" ]]; then
local app_src="$rd/bundle/osx/Cagire.app" local app_src="$rd/bundle/osx/Cagire.app"
if [[ -d "$app_src" ]]; then if [[ ! -d "$app_src" ]]; then
local app_dst="$OUT/Cagire-${arch}.app" echo " ERROR: .app bundle not found at $app_src"
rm -rf "$app_dst" echo " Did 'cargo bundle' succeed?"
cp -R "$app_src" "$app_dst" return 1
echo " Cagire.app -> $app_dst"
scripts/make-dmg.sh "$app_dst" "$OUT"
fi fi
local app_dst="$OUT/Cagire-${arch}.app"
rm -rf "$app_dst"
cp -R "$app_src" "$app_dst"
echo " Cagire.app -> $app_dst"
scripts/make-dmg.sh "$app_dst" "$OUT"
fi fi
fi fi

View File

@@ -407,7 +407,7 @@ impl App {
} }
} }
AppCommand::AudioTriggerRestart => self.audio.trigger_restart(), AppCommand::AudioTriggerRestart => self.audio.trigger_restart(),
AppCommand::RemoveLastSamplePath => self.audio.remove_last_sample_path(), AppCommand::RemoveSamplePath(index) => self.audio.remove_sample_path(index),
AppCommand::AudioRefreshDevices => self.audio.refresh_devices(), AppCommand::AudioRefreshDevices => self.audio.refresh_devices(),
// Options page // Options page

View File

@@ -21,6 +21,7 @@ impl App {
channels: self.audio.config.channels, channels: self.audio.config.channels,
buffer_size: self.audio.config.buffer_size, buffer_size: self.audio.config.buffer_size,
max_voices: self.audio.config.max_voices, max_voices: self.audio.config.max_voices,
sample_paths: self.audio.config.sample_paths.clone(),
}, },
display: crate::settings::DisplaySettings { display: crate::settings::DisplaySettings {
fps: self.audio.config.refresh_rate.to_fps(), fps: self.audio.config.refresh_rate.to_fps(),

View File

@@ -245,11 +245,12 @@ impl CagireDesktop {
self.stream_error_rx = new_error_rx; self.stream_error_rx = new_error_rx;
let mut restart_samples = Vec::new(); let mut restart_samples = Vec::new();
self.app.audio.config.sample_counts.clear();
for path in &self.app.audio.config.sample_paths { for path in &self.app.audio.config.sample_paths {
let index = doux::sampling::scan_samples_dir(path); let index = doux::sampling::scan_samples_dir(path);
self.app.audio.config.sample_counts.push(index.len());
restart_samples.extend(index); restart_samples.extend(index);
} }
self.app.audio.config.sample_count = restart_samples.len();
self.audio_sample_pos.store(0, Ordering::Release); self.audio_sample_pos.store(0, Ordering::Release);

View File

@@ -267,7 +267,7 @@ pub enum AppCommand {
delta: i32, delta: i32,
}, },
AudioTriggerRestart, AudioTriggerRestart,
RemoveLastSamplePath, RemoveSamplePath(usize),
AudioRefreshDevices, AudioRefreshDevices,
// Options page // Options page

View File

@@ -104,7 +104,11 @@ pub fn init(args: InitArgs) -> Init {
app.audio.config.channels = args.channels.unwrap_or(settings.audio.channels); app.audio.config.channels = args.channels.unwrap_or(settings.audio.channels);
app.audio.config.buffer_size = args.buffer.unwrap_or(settings.audio.buffer_size); app.audio.config.buffer_size = args.buffer.unwrap_or(settings.audio.buffer_size);
app.audio.config.max_voices = settings.audio.max_voices; app.audio.config.max_voices = settings.audio.max_voices;
app.audio.config.sample_paths = args.samples; app.audio.config.sample_paths = if args.samples.is_empty() {
settings.audio.sample_paths.clone()
} else {
args.samples
};
app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps); app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps);
app.ui.runtime_highlight = settings.display.runtime_highlight; app.ui.runtime_highlight = settings.display.runtime_highlight;
app.audio.config.show_scope = settings.display.show_scope; app.audio.config.show_scope = settings.display.show_scope;
@@ -154,7 +158,7 @@ pub fn init(args: InitArgs) -> Init {
let mut initial_samples = Vec::new(); let mut initial_samples = Vec::new();
for path in &app.audio.config.sample_paths { for path in &app.audio.config.sample_paths {
let index = doux::sampling::scan_samples_dir(path); let index = doux::sampling::scan_samples_dir(path);
app.audio.config.sample_count += index.len(); app.audio.config.sample_counts.push(index.len());
initial_samples.extend(index); initial_samples.extend(index);
} }
let preload_entries: Vec<(String, std::path::PathBuf)> = initial_samples let preload_entries: Vec<(String, std::path::PathBuf)> = initial_samples
@@ -238,6 +242,7 @@ pub fn init(args: InitArgs) -> Init {
} }
}; };
app.evaluate_prelude(&link);
app.mark_all_patterns_dirty(); app.mark_all_patterns_dirty();
Init { Init {

View File

@@ -49,6 +49,9 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
EngineSection::Settings => { EngineSection::Settings => {
ctx.dispatch(AppCommand::AudioSettingPrev); ctx.dispatch(AppCommand::AudioSettingPrev);
} }
EngineSection::Samples => {
ctx.app.audio.sample_list.move_up();
}
_ => {} _ => {}
}, },
KeyCode::Down => match ctx.app.audio.section { KeyCode::Down => match ctx.app.audio.section {
@@ -65,6 +68,10 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
EngineSection::Settings => { EngineSection::Settings => {
ctx.dispatch(AppCommand::AudioSettingNext); ctx.dispatch(AppCommand::AudioSettingNext);
} }
EngineSection::Samples => {
let count = ctx.app.audio.config.sample_paths.len();
ctx.app.audio.sample_list.move_down(count);
}
_ => {} _ => {}
}, },
KeyCode::PageUp => { KeyCode::PageUp => {
@@ -128,14 +135,16 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
KeyCode::Char('R') if !ctx.app.plugin_mode => { KeyCode::Char('R') if !ctx.app.plugin_mode => {
ctx.dispatch(AppCommand::AudioTriggerRestart); ctx.dispatch(AppCommand::AudioTriggerRestart);
} }
KeyCode::Char('A') => { KeyCode::Char('A') if ctx.app.audio.section == EngineSection::Samples => {
use crate::state::file_browser::FileBrowserState; use crate::state::file_browser::FileBrowserState;
let state = FileBrowserState::new_load(String::new()); let state = FileBrowserState::new_load(String::new());
ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(Box::new(state)))); ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(Box::new(state))));
} }
KeyCode::Char('D') => { KeyCode::Char('D') => {
if ctx.app.audio.section == EngineSection::Samples { if ctx.app.audio.section == EngineSection::Samples {
ctx.dispatch(AppCommand::RemoveLastSamplePath); let cursor = ctx.app.audio.sample_list.cursor;
ctx.dispatch(AppCommand::RemoveSamplePath(cursor));
ctx.app.save_settings(ctx.link);
} else if !ctx.app.plugin_mode { } else if !ctx.app.plugin_mode {
ctx.dispatch(AppCommand::AudioRefreshDevices); ctx.dispatch(AppCommand::AudioRefreshDevices);
let out_count = ctx.app.audio.output_devices.len(); let out_count = ctx.app.audio.output_devices.len();

View File

@@ -69,9 +69,7 @@ pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
if ctx.app.ui.show_title { if ctx.app.ui.show_title {
ctx.dispatch(AppCommand::HideTitle); ctx.dispatch(AppCommand::HideTitle);
if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) { return InputResult::Continue;
return InputResult::Continue;
}
} }
ctx.dispatch(AppCommand::ClearStatus); ctx.dispatch(AppCommand::ClearStatus);
@@ -134,7 +132,7 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
} }
if ctrl { if ctrl {
let minimap_timed = MinimapMode::Timed(Instant::now() + Duration::from_millis(250)); let minimap_timed = MinimapMode::Timed(Instant::now() + Duration::from_millis(1000));
match key.code { match key.code {
KeyCode::Left => { KeyCode::Left => {
ctx.app.ui.minimap = minimap_timed; ctx.app.ui.minimap = minimap_timed;
@@ -170,7 +168,7 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
KeyCode::F(7) => Some(Page::Script), KeyCode::F(7) => Some(Page::Script),
_ => None, _ => None,
} { } {
ctx.app.ui.minimap = MinimapMode::Timed(Instant::now() + Duration::from_millis(250)); ctx.app.ui.minimap = MinimapMode::Timed(Instant::now() + Duration::from_millis(1000));
ctx.dispatch(AppCommand::GoToPage(page)); ctx.dispatch(AppCommand::GoToPage(page));
return InputResult::Continue; return InputResult::Continue;
} }
@@ -226,21 +224,25 @@ fn load_project_samples(ctx: &mut InputContext) {
} }
let mut total_count = 0; let mut total_count = 0;
let mut counts = Vec::new();
let mut all_preload_entries = Vec::new(); let mut all_preload_entries = Vec::new();
for path in &paths { for path in &paths {
if path.is_dir() { if path.is_dir() {
let index = doux::sampling::scan_samples_dir(path); let index = doux::sampling::scan_samples_dir(path);
let count = index.len(); let count = index.len();
total_count += count; total_count += count;
counts.push(count);
for e in &index { for e in &index {
all_preload_entries.push((e.name.clone(), e.path.clone())); all_preload_entries.push((e.name.clone(), e.path.clone()));
} }
let _ = ctx.audio_tx.load().send(AudioCommand::LoadSamples(index)); let _ = ctx.audio_tx.load().send(AudioCommand::LoadSamples(index));
} else {
counts.push(0);
} }
} }
ctx.app.audio.config.sample_paths = paths; ctx.app.audio.config.sample_paths = paths;
ctx.app.audio.config.sample_count = total_count; ctx.app.audio.config.sample_counts = counts;
for path in &ctx.app.audio.config.sample_paths { for path in &ctx.app.audio.config.sample_paths {
if let Some(sf2_path) = doux::soundfont::find_sf2_file(path) { if let Some(sf2_path) = doux::soundfont::find_sf2_file(path) {

View File

@@ -245,8 +245,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
if let Some(sf2_path) = doux::soundfont::find_sf2_file(&path) { if let Some(sf2_path) = doux::soundfont::find_sf2_file(&path) {
let _ = ctx.audio_tx.load().send(crate::engine::AudioCommand::LoadSoundfont(sf2_path)); let _ = ctx.audio_tx.load().send(crate::engine::AudioCommand::LoadSoundfont(sf2_path));
} }
ctx.app.audio.config.sample_count += count; ctx.app.audio.add_sample_path(path, count);
ctx.app.audio.add_sample_path(path);
if let Some(registry) = ctx.app.audio.sample_registry.clone() { if let Some(registry) = ctx.app.audio.sample_registry.clone() {
let sr = ctx.app.audio.config.sample_rate; let sr = ctx.app.audio.config.sample_rate;
std::thread::Builder::new() std::thread::Builder::new()
@@ -256,6 +255,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
}) })
.expect("failed to spawn preload thread"); .expect("failed to spawn preload thread");
} }
ctx.app.save_settings(ctx.link);
ctx.dispatch(AppCommand::SetStatus(format!("Added {count} samples"))); ctx.dispatch(AppCommand::SetStatus(format!("Added {count} samples")));
ctx.dispatch(AppCommand::CloseModal); ctx.dispatch(AppCommand::CloseModal);
} }

View File

@@ -134,11 +134,12 @@ fn main() -> io::Result<()> {
stream_error_rx = new_error_rx; stream_error_rx = new_error_rx;
let mut restart_samples = Vec::new(); let mut restart_samples = Vec::new();
app.audio.config.sample_counts.clear();
for path in &app.audio.config.sample_paths { for path in &app.audio.config.sample_paths {
let index = doux::sampling::scan_samples_dir(path); let index = doux::sampling::scan_samples_dir(path);
app.audio.config.sample_counts.push(index.len());
restart_samples.extend(index); restart_samples.extend(index);
} }
app.audio.config.sample_count = restart_samples.len();
audio_sample_pos.store(0, Ordering::Relaxed); audio_sample_pos.store(0, Ordering::Relaxed);
@@ -277,9 +278,6 @@ fn main() -> io::Result<()> {
app.metrics.nudge_ms = nudge_us.load(Ordering::Relaxed) as f64 / 1000.0; app.metrics.nudge_ms = nudge_us.load(Ordering::Relaxed) as f64 / 1000.0;
} }
let seq_snapshot = sequencer.snapshot();
app.metrics.event_count = seq_snapshot.event_count;
app.flush_dirty_patterns(&sequencer.cmd_tx); app.flush_dirty_patterns(&sequencer.cmd_tx);
app.flush_dirty_script(&sequencer.cmd_tx); app.flush_dirty_script(&sequencer.cmd_tx);
app.flush_queued_changes(&sequencer.cmd_tx); app.flush_queued_changes(&sequencer.cmd_tx);
@@ -288,6 +286,9 @@ fn main() -> io::Result<()> {
app.audio.config.refresh_rate.millis(), app.audio.config.refresh_rate.millis(),
))?; ))?;
let seq_snapshot = sequencer.snapshot();
app.metrics.event_count = seq_snapshot.event_count;
if had_event { if had_event {
match event::read()? { match event::read()? {
Event::Key(key) => { Event::Key(key) => {
@@ -340,7 +341,7 @@ fn main() -> io::Result<()> {
|| app.ui.modal_fx.borrow().is_some() || app.ui.modal_fx.borrow().is_some()
|| app.ui.title_fx.borrow().is_some() || app.ui.title_fx.borrow().is_some()
|| app.ui.nav_fx.borrow().is_some(); || app.ui.nav_fx.borrow().is_some();
if app.playback.playing || had_event || app.ui.show_title || effects_active { if app.playback.playing || had_event || app.ui.show_title || effects_active || app.ui.show_minimap() {
if app.ui.show_title { if app.ui.show_title {
app.ui.sparkles.tick(terminal.get_frame().area()); app.ui.sparkles.tick(terminal.get_frame().area());
} }

View File

@@ -97,12 +97,14 @@ pub fn delete_step(project: &mut Project, bank: usize, pattern: usize, step: usi
if s.source == Some(step as u8) { if s.source == Some(step as u8) {
s.source = None; s.source = None;
s.script.clear(); s.script.clear();
s.name = None;
} }
} }
set_step_script(project, bank, pattern, step, String::new()); set_step_script(project, bank, pattern, step, String::new());
if let Some(s) = project.pattern_at_mut(bank, pattern).step_mut(step) { if let Some(s) = project.pattern_at_mut(bank, pattern).step_mut(step) {
s.source = None; s.source = None;
s.name = None;
} }
PatternEdit::new(bank, pattern) PatternEdit::new(bank, pattern)
} }

View File

@@ -1,3 +1,5 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::state::{ColorScheme, MainLayout}; use crate::state::{ColorScheme, MainLayout};
@@ -30,6 +32,8 @@ pub struct AudioSettings {
pub buffer_size: u32, pub buffer_size: u32,
#[serde(default = "default_max_voices")] #[serde(default = "default_max_voices")]
pub max_voices: usize, pub max_voices: usize,
#[serde(default)]
pub sample_paths: Vec<PathBuf>,
} }
fn default_max_voices() -> usize { 32 } fn default_max_voices() -> usize { 32 }
@@ -97,6 +101,7 @@ impl Default for AudioSettings {
channels: 2, channels: 2,
buffer_size: 512, buffer_size: 512,
max_voices: 32, max_voices: 32,
sample_paths: Vec::new(),
} }
} }
} }

View File

@@ -113,7 +113,7 @@ pub struct AudioConfig {
pub sample_rate: f32, pub sample_rate: f32,
pub host_name: String, pub host_name: String,
pub sample_paths: Vec<PathBuf>, pub sample_paths: Vec<PathBuf>,
pub sample_count: usize, pub sample_counts: Vec<usize>,
pub refresh_rate: RefreshRate, pub refresh_rate: RefreshRate,
pub show_scope: bool, pub show_scope: bool,
pub show_spectrum: bool, pub show_spectrum: bool,
@@ -140,7 +140,7 @@ impl Default for AudioConfig {
sample_rate: 44100.0, sample_rate: 44100.0,
host_name: String::new(), host_name: String::new(),
sample_paths: Vec::new(), sample_paths: Vec::new(),
sample_count: 0, sample_counts: Vec::new(),
refresh_rate: RefreshRate::default(), refresh_rate: RefreshRate::default(),
show_scope: true, show_scope: true,
show_spectrum: true, show_spectrum: true,
@@ -275,6 +275,7 @@ pub struct AudioSettings {
pub input_devices: Vec<AudioDeviceInfo>, pub input_devices: Vec<AudioDeviceInfo>,
pub output_list: ListSelectState, pub output_list: ListSelectState,
pub input_list: ListSelectState, pub input_list: ListSelectState,
pub sample_list: ListSelectState,
pub restart_pending: bool, pub restart_pending: bool,
pub error: Option<String>, pub error: Option<String>,
pub sample_registry: Option<std::sync::Arc<doux::SampleRegistry>>, pub sample_registry: Option<std::sync::Arc<doux::SampleRegistry>>,
@@ -297,6 +298,10 @@ impl Default for AudioSettings {
cursor: 0, cursor: 0,
scroll_offset: 0, scroll_offset: 0,
}, },
sample_list: ListSelectState {
cursor: 0,
scroll_offset: 0,
},
restart_pending: false, restart_pending: false,
error: None, error: None,
sample_registry: None, sample_registry: None,
@@ -321,6 +326,10 @@ impl AudioSettings {
cursor: 0, cursor: 0,
scroll_offset: 0, scroll_offset: 0,
}, },
sample_list: ListSelectState {
cursor: 0,
scroll_offset: 0,
},
restart_pending: false, restart_pending: false,
error: None, error: None,
sample_registry: None, sample_registry: None,
@@ -429,14 +438,29 @@ impl AudioSettings {
self.config.refresh_rate = self.config.refresh_rate.toggle(); self.config.refresh_rate = self.config.refresh_rate.toggle();
} }
pub fn add_sample_path(&mut self, path: PathBuf) { pub fn total_sample_count(&self) -> usize {
self.config.sample_counts.iter().sum()
}
pub fn add_sample_path(&mut self, path: PathBuf, count: usize) {
if !self.config.sample_paths.contains(&path) { if !self.config.sample_paths.contains(&path) {
self.config.sample_paths.push(path); self.config.sample_paths.push(path);
self.config.sample_counts.push(count);
} }
} }
pub fn remove_last_sample_path(&mut self) { pub fn remove_sample_path(&mut self, index: usize) {
self.config.sample_paths.pop(); if index < self.config.sample_paths.len() {
self.config.sample_paths.remove(index);
self.config.sample_counts.remove(index);
let len = self.config.sample_paths.len();
if len == 0 {
self.sample_list.cursor = 0;
self.sample_list.scroll_offset = 0;
} else if self.sample_list.cursor >= len {
self.sample_list.cursor = len - 1;
}
}
} }
pub fn trigger_restart(&mut self) { pub fn trigger_restart(&mut self) {

View File

@@ -48,6 +48,7 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
}; };
// Calculate section heights // Calculate section heights
let intro_lines: usize = 3;
let plugin_mode = app.plugin_mode; let plugin_mode = app.plugin_mode;
let devices_lines = if plugin_mode { let devices_lines = if plugin_mode {
0 0
@@ -55,17 +56,19 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
devices_section_height(app) as usize devices_section_height(app) as usize
}; };
let settings_lines: usize = if plugin_mode { 5 } else { 8 }; // plugin: header(1) + divider(1) + 3 rows let settings_lines: usize = if plugin_mode { 5 } else { 8 }; // plugin: header(1) + divider(1) + 3 rows
let samples_lines: usize = 6; // header(1) + divider(1) + content(3) + hint(1) let sample_content = app.audio.config.sample_paths.len().max(2); // at least 2 for empty message
let samples_lines: usize = 2 + sample_content; // header(2) + content
let sections_gap = if plugin_mode { 1 } else { 2 }; // 1 gap without devices, 2 gaps with let sections_gap = if plugin_mode { 1 } else { 2 }; // 1 gap without devices, 2 gaps with
let total_lines = devices_lines + settings_lines + samples_lines + sections_gap; let total_lines = intro_lines + 1 + devices_lines + settings_lines + samples_lines + sections_gap;
let max_visible = padded.height as usize; let max_visible = padded.height as usize;
// Calculate scroll offset based on focused section // Calculate scroll offset based on focused section
let settings_start = if plugin_mode { 0 } else { devices_lines + 1 }; let intro_offset = intro_lines + 1;
let settings_start = if plugin_mode { intro_offset } else { intro_offset + devices_lines + 1 };
let (focus_start, focus_height) = match app.audio.section { let (focus_start, focus_height) = match app.audio.section {
EngineSection::Devices => (0, devices_lines), EngineSection::Devices => (intro_offset, devices_lines),
EngineSection::Settings => (settings_start, settings_lines), EngineSection::Settings => (settings_start, settings_lines),
EngineSection::Samples => (settings_start + settings_lines + 1, samples_lines), EngineSection::Samples => (settings_start + settings_lines + 1, samples_lines),
}; };
@@ -86,6 +89,29 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
let mut y = viewport_top - scroll_offset as i32; let mut y = viewport_top - scroll_offset as i32;
// Intro text
let intro_top = y;
let intro_bottom = y + intro_lines as i32;
if intro_bottom > viewport_top && intro_top < viewport_bottom {
let clipped_y = intro_top.max(viewport_top) as u16;
let clipped_height =
(intro_bottom.min(viewport_bottom) - intro_top.max(viewport_top)) as u16;
let intro_area = Rect {
x: padded.x,
y: clipped_y,
width: padded.width,
height: clipped_height,
};
let dim = Style::new().fg(theme.engine.dim);
let intro = Paragraph::new(vec![
Line::from(Span::styled(" Audio devices, settings, and sample paths.", dim)),
Line::from(Span::styled(" Supports .wav, .ogg, .mp3 samples and .sf2 soundfonts.", dim)),
Line::from(Span::styled(" Press R to restart the audio engine after changes.", dim)),
]);
frame.render_widget(intro, intro_area);
}
y += intro_lines as i32 + 1;
// Devices section (skip in plugin mode) // Devices section (skip in plugin mode)
if !plugin_mode { if !plugin_mode {
let devices_top = y; let devices_top = y;
@@ -495,21 +521,26 @@ fn render_samples(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get(); let theme = theme::get();
let section_focused = app.audio.section == EngineSection::Samples; let section_focused = app.audio.section == EngineSection::Samples;
let [header_area, content_area, _, hint_area] = Layout::vertical([ let [header_area, content_area] = Layout::vertical([
Constraint::Length(2), Constraint::Length(2),
Constraint::Min(1), Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(1),
]) ])
.areas(area); .areas(area);
let path_count = app.audio.config.sample_paths.len(); let path_count = app.audio.config.sample_paths.len();
let sample_count = app.audio.config.sample_count; let sample_count: usize = app.audio.total_sample_count();
let header_text = format!("SAMPLES {path_count} paths · {sample_count} indexed"); let header_text = format!("SAMPLES {path_count} paths · {sample_count} indexed");
render_section_header(frame, &header_text, section_focused, header_area); render_section_header(frame, &header_text, section_focused, header_area);
let dim = Style::new().fg(theme.engine.dim); let dim = Style::new().fg(theme.engine.dim);
let path_style = Style::new().fg(theme.engine.path); let path_style = Style::new().fg(theme.engine.path);
let cursor_style = Style::new()
.fg(theme.engine.focused)
.add_modifier(Modifier::BOLD);
let cursor = app.audio.sample_list.cursor;
let scroll_offset = app.audio.sample_list.scroll_offset;
let visible_rows = content_area.height as usize;
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
if app.audio.config.sample_paths.is_empty() { if app.audio.config.sample_paths.is_empty() {
@@ -522,35 +553,32 @@ fn render_samples(frame: &mut Frame, app: &App, area: Rect) {
dim, dim,
))); )));
} else { } else {
for (i, path) in app.audio.config.sample_paths.iter().take(4).enumerate() { for (i, path) in app
.audio
.config
.sample_paths
.iter()
.enumerate()
.skip(scroll_offset)
.take(visible_rows)
{
let is_cursor = section_focused && i == cursor;
let prefix = if is_cursor { "> " } else { " " };
let count = app.audio.config.sample_counts.get(i).copied().unwrap_or(0);
let path_str = path.to_string_lossy(); let path_str = path.to_string_lossy();
let display = truncate_name(&path_str, 40); let count_str = format!(" ({count})");
let max_path = (content_area.width as usize)
.saturating_sub(prefix.len() + count_str.len());
let display = truncate_name(&path_str, max_path);
let style = if is_cursor { cursor_style } else { path_style };
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(format!(" {} ", i + 1), dim), Span::styled(prefix.to_string(), if is_cursor { cursor_style } else { dim }),
Span::styled(display, path_style), Span::styled(display, style),
Span::styled(count_str, dim),
])); ]));
} }
if path_count > 4 {
lines.push(Line::from(Span::styled(
format!(" ... and {} more", path_count - 4),
dim,
)));
}
} }
frame.render_widget(Paragraph::new(lines), content_area); frame.render_widget(Paragraph::new(lines), content_area);
let hint_style = if section_focused {
Style::new().fg(theme.engine.hint_active)
} else {
Style::new().fg(theme.engine.hint_inactive)
};
let hint = Line::from(vec![
Span::styled("A", hint_style),
Span::styled(":add ", Style::new().fg(theme.engine.dim)),
Span::styled("D", hint_style),
Span::styled(":remove", Style::new().fg(theme.engine.dim)),
]);
frame.render_widget(Paragraph::new(hint), hint_area);
} }
fn render_selector(value: &str, focused: bool, highlight: Style, normal: Style) -> Span<'static> { fn render_selector(value: &str, focused: bool, highlight: Style, normal: Style) -> Span<'static> {

View File

@@ -551,15 +551,16 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
Page::Patterns => vec![ Page::Patterns => vec![
("Enter", "Select"), ("Enter", "Select"),
("Space", "Play"), ("Space", "Play"),
("c", "Commit"),
("r", "Rename"), ("r", "Rename"),
("?", "Keys"), ("?", "Keys"),
], ],
Page::Engine => vec![ Page::Engine => vec![
("Tab", "Section"), ("Tab", "Section"),
("←→", "Switch/Adjust"), ("←→", "Switch/Adjust"),
("Enter", "Select"), ("A", "Add Samples"),
("D", "Remove"),
("R", "Restart"), ("R", "Restart"),
("h", "Hush"),
("?", "Keys"), ("?", "Keys"),
], ],
Page::Options => vec![ Page::Options => vec![

View File

@@ -187,30 +187,30 @@ fn nor_ff() {
#[test] #[test]
fn ifelse_true() { fn ifelse_true() {
expect_int("{ 42 } { 99 } 1 ifelse", 42); expect_int("( 42 ) ( 99 ) 1 ifelse", 42);
} }
#[test] #[test]
fn ifelse_false() { fn ifelse_false() {
expect_int("{ 42 } { 99 } 0 ifelse", 99); expect_int("( 42 ) ( 99 ) 0 ifelse", 99);
} }
#[test] #[test]
fn select_first() { fn select_first() {
expect_int("{ 10 } { 20 } { 30 } 0 select", 10); expect_int("( 10 ) ( 20 ) ( 30 ) 0 select", 10);
} }
#[test] #[test]
fn select_second() { fn select_second() {
expect_int("{ 10 } { 20 } { 30 } 1 select", 20); expect_int("( 10 ) ( 20 ) ( 30 ) 1 select", 20);
} }
#[test] #[test]
fn select_third() { fn select_third() {
expect_int("{ 10 } { 20 } { 30 } 2 select", 30); expect_int("( 10 ) ( 20 ) ( 30 ) 2 select", 30);
} }
#[test] #[test]
fn select_preserves_stack() { fn select_preserves_stack() {
expect_int("5 { 10 } { 20 } 0 select +", 15); expect_int("5 ( 10 ) ( 20 ) 0 select +", 15);
} }

View File

@@ -59,14 +59,14 @@ fn iter() {
#[test] #[test]
fn every_true_on_zero() { fn every_true_on_zero() {
let ctx = ctx_with(|c| c.iter = 0); let ctx = ctx_with(|c| c.iter = 0);
let f = run_ctx("{ 100 } 4 every", &ctx); let f = run_ctx("( 100 ) 4 every", &ctx);
assert_eq!(stack_int(&f), 100); assert_eq!(stack_int(&f), 100);
} }
#[test] #[test]
fn every_true_on_multiple() { fn every_true_on_multiple() {
let ctx = ctx_with(|c| c.iter = 8); let ctx = ctx_with(|c| c.iter = 8);
let f = run_ctx("{ 100 } 4 every", &ctx); let f = run_ctx("( 100 ) 4 every", &ctx);
assert_eq!(stack_int(&f), 100); assert_eq!(stack_int(&f), 100);
} }
@@ -74,14 +74,14 @@ fn every_true_on_multiple() {
fn every_false_between() { fn every_false_between() {
for i in 1..4 { for i in 1..4 {
let ctx = ctx_with(|c| c.iter = i); let ctx = ctx_with(|c| c.iter = i);
let f = run_ctx("{ 100 } 4 every", &ctx); let f = run_ctx("( 100 ) 4 every", &ctx);
assert!(f.stack().is_empty(), "iter={} should not execute quotation", i); assert!(f.stack().is_empty(), "iter={} should not execute quotation", i);
} }
} }
#[test] #[test]
fn every_zero_count() { fn every_zero_count() {
expect_error("{ 1 } 0 every", "every count must be > 0"); expect_error("( 1 ) 0 every", "every count must be > 0");
} }
#[test] #[test]
@@ -105,7 +105,7 @@ fn bjork_tresillo() {
// Bresenham(3,8) hits at positions 0, 2, 5 // Bresenham(3,8) hits at positions 0, 2, 5
for runs in 0..8 { for runs in 0..8 {
let ctx = ctx_with(|c| c.runs = runs); let ctx = ctx_with(|c| c.runs = runs);
let f = run_ctx("{ 100 } 3 8 bjork", &ctx); let f = run_ctx("( 100 ) 3 8 bjork", &ctx);
let hit = ((runs + 1) * 3) / 8 != (runs * 3) / 8; let hit = ((runs + 1) * 3) / 8 != (runs * 3) / 8;
if hit { if hit {
assert_eq!(stack_int(&f), 100, "runs={} should hit", runs); assert_eq!(stack_int(&f), 100, "runs={} should hit", runs);
@@ -121,7 +121,7 @@ fn bjork_hit_count() {
let mut hit_count = 0; let mut hit_count = 0;
for runs in 0..8 { for runs in 0..8 {
let ctx = ctx_with(|c| c.runs = runs); let ctx = ctx_with(|c| c.runs = runs);
let f = run_ctx("{ 100 } 3 8 bjork", &ctx); let f = run_ctx("( 100 ) 3 8 bjork", &ctx);
if !f.stack().is_empty() { if !f.stack().is_empty() {
hit_count += 1; hit_count += 1;
} }
@@ -132,20 +132,20 @@ fn bjork_hit_count() {
#[test] #[test]
fn bjork_all_hits() { fn bjork_all_hits() {
let ctx = ctx_with(|c| c.runs = 0); let ctx = ctx_with(|c| c.runs = 0);
let f = run_ctx("{ 100 } 8 8 bjork", &ctx); let f = run_ctx("( 100 ) 8 8 bjork", &ctx);
assert_eq!(stack_int(&f), 100); assert_eq!(stack_int(&f), 100);
} }
#[test] #[test]
fn bjork_zero_hits() { fn bjork_zero_hits() {
let ctx = ctx_with(|c| c.runs = 0); let ctx = ctx_with(|c| c.runs = 0);
let f = run_ctx("{ 100 } 0 8 bjork", &ctx); let f = run_ctx("( 100 ) 0 8 bjork", &ctx);
assert!(f.stack().is_empty()); assert!(f.stack().is_empty());
} }
#[test] #[test]
fn bjork_invalid() { fn bjork_invalid() {
expect_error("{ 1 } 3 0 bjork", "bjork"); expect_error("( 1 ) 3 0 bjork", "bjork");
} }
// pbjork (iter-based) // pbjork (iter-based)
@@ -155,7 +155,7 @@ fn pbjork_cinquillo() {
let mut hit_count = 0; let mut hit_count = 0;
for iter in 0..8 { for iter in 0..8 {
let ctx = ctx_with(|c| c.iter = iter); let ctx = ctx_with(|c| c.iter = iter);
let f = run_ctx("{ 100 } 5 8 pbjork", &ctx); let f = run_ctx("( 100 ) 5 8 pbjork", &ctx);
if !f.stack().is_empty() { if !f.stack().is_empty() {
hit_count += 1; hit_count += 1;
} }
@@ -167,7 +167,7 @@ fn pbjork_cinquillo() {
fn pbjork_wraps() { fn pbjork_wraps() {
let ctx0 = ctx_with(|c| c.iter = 0); let ctx0 = ctx_with(|c| c.iter = 0);
let ctx8 = ctx_with(|c| c.iter = 8); let ctx8 = ctx_with(|c| c.iter = 8);
let f0 = run_ctx("{ 100 } 3 8 pbjork", &ctx0); let f0 = run_ctx("( 100 ) 3 8 pbjork", &ctx0);
let f8 = run_ctx("{ 100 } 3 8 pbjork", &ctx8); let f8 = run_ctx("( 100 ) 3 8 pbjork", &ctx8);
assert_eq!(f0.stack().is_empty(), f8.stack().is_empty()); assert_eq!(f0.stack().is_empty(), f8.stack().is_empty());
} }

View File

@@ -68,12 +68,12 @@ fn unexpected_semicolon_errors() {
#[test] #[test]
fn apply_executes_quotation() { fn apply_executes_quotation() {
expect_int("5 { 2 * } apply", 10); expect_int("5 ( 2 * ) apply", 10);
} }
#[test] #[test]
fn apply_with_stack_ops() { fn apply_with_stack_ops() {
expect_int("3 4 { + } apply", 7); expect_int("3 4 ( + ) apply", 7);
} }
#[test] #[test]
@@ -88,12 +88,12 @@ fn apply_non_quotation_errors() {
#[test] #[test]
fn apply_nested() { fn apply_nested() {
expect_int("2 { { 3 * } apply } apply", 6); expect_int("2 ( ( 3 * ) apply ) apply", 6);
} }
#[test] #[test]
fn define_word_containing_quotation() { fn define_word_containing_quotation() {
expect_int(": dbl { 2 * } apply ; 7 dbl", 14); expect_int(": dbl ( 2 * ) apply ; 7 dbl", 14);
} }
#[test] #[test]

View File

@@ -33,7 +33,7 @@ fn range_underflow() {
#[test] #[test]
fn gen_basic() { fn gen_basic() {
expect_stack("{ 42 } 3 gen", &[int(42), int(42), int(42)]); expect_stack("( 42 ) 3 gen", &[int(42), int(42), int(42)]);
} }
#[test] #[test]
@@ -42,7 +42,7 @@ fn gen_with_computation() {
// 0 → dup(0,0) 1+(0,1) → pop 1, stack [0] // 0 → dup(0,0) 1+(0,1) → pop 1, stack [0]
// 0 → dup(0,0) 1+(0,1) → pop 1, stack [0] // 0 → dup(0,0) 1+(0,1) → pop 1, stack [0]
// So we get [0, 1, 1, 1] - the 0 stays, we collect three 1s // So we get [0, 1, 1, 1] - the 0 stays, we collect three 1s
expect_stack("0 { dup 1 + } 3 gen", &[int(0), int(1), int(1), int(1)]); expect_stack("0 ( dup 1 + ) 3 gen", &[int(0), int(1), int(1), int(1)]);
} }
#[test] #[test]
@@ -50,12 +50,12 @@ fn gen_chained() {
// Start with 1, each iteration: dup, multiply by 2 // Start with 1, each iteration: dup, multiply by 2
// 1 → dup(1,1) 2*(1,2) → pop 2, stack [1] // 1 → dup(1,1) 2*(1,2) → pop 2, stack [1]
// 1 → dup(1,1) 2*(1,2) → pop 2, stack [1] // 1 → dup(1,1) 2*(1,2) → pop 2, stack [1]
expect_stack("1 { dup 2 * } 3 gen", &[int(1), int(2), int(2), int(2)]); expect_stack("1 ( dup 2 * ) 3 gen", &[int(1), int(2), int(2), int(2)]);
} }
#[test] #[test]
fn gen_zero() { fn gen_zero() {
expect_stack("{ 1 } 0 gen", &[]); expect_stack("( 1 ) 0 gen", &[]);
} }
#[test] #[test]
@@ -65,17 +65,17 @@ fn gen_underflow() {
#[test] #[test]
fn gen_not_a_number() { fn gen_not_a_number() {
expect_error("{ 1 } gen", "expected number"); expect_error("( 1 ) gen", "expected number");
} }
#[test] #[test]
fn gen_negative() { fn gen_negative() {
expect_error("{ 1 } -1 gen", "gen count must be >= 0"); expect_error("( 1 ) -1 gen", "gen count must be >= 0");
} }
#[test] #[test]
fn gen_empty_quot_error() { fn gen_empty_quot_error() {
expect_error("{ } 3 gen", "quotation must produce"); expect_error("( ) 3 gen", "quotation must produce");
} }
#[test] #[test]

View File

@@ -46,20 +46,20 @@ fn pcycle_by_iter() {
#[test] #[test]
fn cycle_with_quotations() { fn cycle_with_quotations() {
let ctx = ctx_with(|c| c.runs = 0); let ctx = ctx_with(|c| c.runs = 0);
let f = run_ctx("5 { dup } { 2 * } 2 cycle", &ctx); let f = run_ctx("5 ( dup ) ( 2 * ) 2 cycle", &ctx);
let stack = f.stack(); let stack = f.stack();
assert_eq!(stack.len(), 2); assert_eq!(stack.len(), 2);
assert_eq!(stack_int(&f), 5); assert_eq!(stack_int(&f), 5);
let ctx = ctx_with(|c| c.runs = 1); let ctx = ctx_with(|c| c.runs = 1);
let f = run_ctx("5 { dup } { 2 * } 2 cycle", &ctx); let f = run_ctx("5 ( dup ) ( 2 * ) 2 cycle", &ctx);
assert_eq!(stack_int(&f), 10); assert_eq!(stack_int(&f), 10);
} }
#[test] #[test]
fn cycle_executes_quotation() { fn cycle_executes_quotation() {
let ctx = ctx_with(|c| c.runs = 0); let ctx = ctx_with(|c| c.runs = 0);
let f = run_ctx("10 { 3 + } { 5 + } 2 cycle", &ctx); let f = run_ctx("10 ( 3 + ) ( 5 + ) 2 cycle", &ctx);
assert_eq!(stack_int(&f), 13); assert_eq!(stack_int(&f), 13);
} }
@@ -108,11 +108,11 @@ fn bracket_cycle() {
#[test] #[test]
fn bracket_with_quotations() { fn bracket_with_quotations() {
let ctx = ctx_with(|c| c.runs = 0); let ctx = ctx_with(|c| c.runs = 0);
let f = run_ctx("5 [ { 3 + } { 5 + } ] cycle", &ctx); let f = run_ctx("5 [ ( 3 + ) ( 5 + ) ] cycle", &ctx);
assert_eq!(stack_int(&f), 8); assert_eq!(stack_int(&f), 8);
let ctx = ctx_with(|c| c.runs = 1); let ctx = ctx_with(|c| c.runs = 1);
let f = run_ctx("5 [ { 3 + } { 5 + } ] cycle", &ctx); let f = run_ctx("5 [ ( 3 + ) ( 5 + ) ] cycle", &ctx);
assert_eq!(stack_int(&f), 10); assert_eq!(stack_int(&f), 10);
} }
@@ -172,7 +172,7 @@ fn index_negative_wraps() {
#[test] #[test]
fn index_with_quotation() { fn index_with_quotation() {
expect_int("5 [ { 3 + } { 5 + } ] 0 index", 8); expect_int("5 [ ( 3 + ) ( 5 + ) ] 0 index", 8);
} }
#[test] #[test]

View File

@@ -4,26 +4,26 @@ use super::harness::*;
fn quotation_on_stack() { fn quotation_on_stack() {
// Quotation should be pushable to stack // Quotation should be pushable to stack
let f = forth(); let f = forth();
let result = f.evaluate("{ 1 2 + }", &default_ctx()); let result = f.evaluate("( 1 2 + )", &default_ctx());
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test] #[test]
fn when_true_executes() { fn when_true_executes() {
let f = run("{ 42 } 1 ?"); let f = run("( 42 ) 1 ?");
assert_eq!(stack_int(&f), 42); assert_eq!(stack_int(&f), 42);
} }
#[test] #[test]
fn when_false_skips() { fn when_false_skips() {
let f = run("99 { 42 } 0 ?"); let f = run("99 ( 42 ) 0 ?");
// Stack should still have 99, quotation not executed // Stack should still have 99, quotation not executed
assert_eq!(stack_int(&f), 99); assert_eq!(stack_int(&f), 99);
} }
#[test] #[test]
fn when_with_arithmetic() { fn when_with_arithmetic() {
let f = run("10 { 5 + } 1 ?"); let f = run("10 ( 5 + ) 1 ?");
assert_eq!(stack_int(&f), 15); assert_eq!(stack_int(&f), 15);
} }
@@ -31,48 +31,48 @@ fn when_with_arithmetic() {
fn when_with_every() { fn when_with_every() {
// iter=0, every 2 executes quotation // iter=0, every 2 executes quotation
let ctx = ctx_with(|c| c.iter = 0); let ctx = ctx_with(|c| c.iter = 0);
let f = run_ctx("{ 100 } 2 every", &ctx); let f = run_ctx("( 100 ) 2 every", &ctx);
assert_eq!(stack_int(&f), 100); assert_eq!(stack_int(&f), 100);
// iter=1, every 2 skips quotation // iter=1, every 2 skips quotation
let ctx = ctx_with(|c| c.iter = 1); let ctx = ctx_with(|c| c.iter = 1);
let f = run_ctx("50 { 100 } 2 every", &ctx); let f = run_ctx("50 ( 100 ) 2 every", &ctx);
assert_eq!(stack_int(&f), 50); // quotation not executed assert_eq!(stack_int(&f), 50); // quotation not executed
} }
#[test] #[test]
fn when_with_chance_deterministic() { fn when_with_chance_deterministic() {
// 1.0 chance always executes quotation // 1.0 chance always executes quotation
let f = run("{ 42 } 1.0 chance"); let f = run("( 42 ) 1.0 chance");
assert_eq!(stack_int(&f), 42); assert_eq!(stack_int(&f), 42);
// 0.0 chance never executes quotation // 0.0 chance never executes quotation
let f = run("99 { 42 } 0.0 chance"); let f = run("99 ( 42 ) 0.0 chance");
assert_eq!(stack_int(&f), 99); assert_eq!(stack_int(&f), 99);
} }
#[test] #[test]
fn nested_quotations() { fn nested_quotations() {
let f = run("{ { 42 } 1 ? } 1 ?"); let f = run("( ( 42 ) 1 ? ) 1 ?");
assert_eq!(stack_int(&f), 42); assert_eq!(stack_int(&f), 42);
} }
#[test] #[test]
fn quotation_with_param() { fn quotation_with_param() {
let outputs = expect_outputs(r#""kick" s { 2 distort } 1 ? ."#, 1); let outputs = expect_outputs(r#""kick" s ( 2 distort ) 1 ? ."#, 1);
assert!(outputs[0].contains("distort/2")); assert!(outputs[0].contains("distort/2"));
} }
#[test] #[test]
fn quotation_skips_param() { fn quotation_skips_param() {
let outputs = expect_outputs(r#""kick" s { 2 distort } 0 ? ."#, 1); let outputs = expect_outputs(r#""kick" s ( 2 distort ) 0 ? ."#, 1);
assert!(!outputs[0].contains("distort")); assert!(!outputs[0].contains("distort"));
} }
#[test] #[test]
fn quotation_with_emit() { fn quotation_with_emit() {
// When true, . should fire // When true, . should fire
let outputs = expect_outputs(r#""kick" s { . } 1 ?"#, 1); let outputs = expect_outputs(r#""kick" s ( . ) 1 ?"#, 1);
assert!(outputs[0].contains("kick")); assert!(outputs[0].contains("kick"));
} }
@@ -81,7 +81,7 @@ fn quotation_skips_emit() {
// When false, . should not fire // When false, . should not fire
let f = forth(); let f = forth();
let outputs = f let outputs = f
.evaluate(r#""kick" s { . } 0 ?"#, &default_ctx()) .evaluate(r#""kick" s ( . ) 0 ?"#, &default_ctx())
.unwrap(); .unwrap();
// No output since . was skipped and no implicit emit // No output since . was skipped and no implicit emit
assert_eq!(outputs.len(), 0); assert_eq!(outputs.len(), 0);
@@ -94,22 +94,22 @@ fn missing_quotation_error() {
#[test] #[test]
fn unclosed_quotation_error() { fn unclosed_quotation_error() {
expect_error("{ 1 2", "missing }"); expect_error("( 1 2", "missing )");
} }
#[test] #[test]
fn unexpected_close_error() { fn unexpected_close_error() {
expect_error("1 2 }", "unexpected }"); expect_error("1 2 )", "unexpected )");
} }
#[test] #[test]
fn every_with_quotation_integration() { fn every_with_quotation_integration() {
// { 2 distort } 2 every — on even iterations, distort is applied // ( 2 distort ) 2 every — on even iterations, distort is applied
for iter in 0..4 { for iter in 0..4 {
let ctx = ctx_with(|c| c.iter = iter); let ctx = ctx_with(|c| c.iter = iter);
let f = forth(); let f = forth();
let outputs = f let outputs = f
.evaluate(r#""kick" s { 2 distort } 2 every ."#, &ctx) .evaluate(r#""kick" s ( 2 distort ) 2 every ."#, &ctx)
.unwrap(); .unwrap();
if iter % 2 == 0 { if iter % 2 == 0 {
assert!( assert!(
@@ -134,7 +134,7 @@ fn bjork_with_sound() {
let ctx = ctx_with(|c| c.runs = 2); // position 2 is a hit for (3,8) let ctx = ctx_with(|c| c.runs = 2); // position 2 is a hit for (3,8)
let f = forth(); let f = forth();
let outputs = f let outputs = f
.evaluate(r#""kick" s { 2 distort } 3 8 bjork ."#, &ctx) .evaluate(r#""kick" s ( 2 distort ) 3 8 bjork ."#, &ctx)
.unwrap(); .unwrap();
assert!(outputs[0].contains("distort/2")); assert!(outputs[0].contains("distort/2"));
} }
@@ -143,13 +143,13 @@ fn bjork_with_sound() {
#[test] #[test]
fn unless_false_executes() { fn unless_false_executes() {
let f = run("{ 42 } 0 !?"); let f = run("( 42 ) 0 !?");
assert_eq!(stack_int(&f), 42); assert_eq!(stack_int(&f), 42);
} }
#[test] #[test]
fn unless_true_skips() { fn unless_true_skips() {
let f = run("99 { 42 } 1 !?"); let f = run("99 ( 42 ) 1 !?");
assert_eq!(stack_int(&f), 99); assert_eq!(stack_int(&f), 99);
} }
@@ -161,7 +161,7 @@ fn when_and_unless_complementary() {
let f = forth(); let f = forth();
let outputs = f let outputs = f
.evaluate( .evaluate(
r#""kick" s { 2 distort } iter 2 mod 0 = ? { 4 distort } iter 2 mod 0 = !? ."#, r#""kick" s ( 2 distort ) iter 2 mod 0 = ? ( 4 distort ) iter 2 mod 0 = !? ."#,
&ctx, &ctx,
) )
.unwrap(); .unwrap();

View File

@@ -38,14 +38,14 @@ fn coin_binary() {
#[test] #[test]
fn chance_zero() { fn chance_zero() {
// 0.0 probability should never execute the quotation // 0.0 probability should never execute the quotation
let f = run("99 { 42 } 0.0 chance"); let f = run("99 ( 42 ) 0.0 chance");
assert_eq!(stack_int(&f), 99); // quotation not executed, 99 still on stack assert_eq!(stack_int(&f), 99); // quotation not executed, 99 still on stack
} }
#[test] #[test]
fn chance_one() { fn chance_one() {
// 1.0 probability should always execute the quotation // 1.0 probability should always execute the quotation
let f = run("{ 42 } 1.0 chance"); let f = run("( 42 ) 1.0 chance");
assert_eq!(stack_int(&f), 42); assert_eq!(stack_int(&f), 42);
} }
@@ -281,7 +281,7 @@ fn wchoose_negative_weight() {
#[test] #[test]
fn wchoose_quotation() { fn wchoose_quotation() {
let f = forth_seeded(42); let f = forth_seeded(42);
f.evaluate("{ 10 } 0.0 { 20 } 1.0 2 wchoose", &default_ctx()) f.evaluate("( 10 ) 0.0 ( 20 ) 1.0 2 wchoose", &default_ctx())
.unwrap(); .unwrap();
assert_eq!(stack_int(&f), 20); assert_eq!(stack_int(&f), 20);
} }

View File

@@ -99,7 +99,7 @@ fn cycle_picks_by_runs() {
for runs in 0..4 { for runs in 0..4 {
let ctx = ctx_with(|c| c.runs = runs); let ctx = ctx_with(|c| c.runs = runs);
let f = forth(); let f = forth();
let outputs = f.evaluate(r#""kick" s { . } { } 2 cycle"#, &ctx).unwrap(); let outputs = f.evaluate(r#""kick" s ( . ) ( ) 2 cycle"#, &ctx).unwrap();
if runs % 2 == 0 { if runs % 2 == 0 {
assert_eq!(outputs.len(), 1, "runs={}: emit should be picked", runs); assert_eq!(outputs.len(), 1, "runs={}: emit should be picked", runs);
} else { } else {
@@ -113,7 +113,7 @@ fn pcycle_picks_by_iter() {
for iter in 0..4 { for iter in 0..4 {
let ctx = ctx_with(|c| c.iter = iter); let ctx = ctx_with(|c| c.iter = iter);
let f = forth(); let f = forth();
let outputs = f.evaluate(r#""kick" s { . } { } 2 pcycle"#, &ctx).unwrap(); let outputs = f.evaluate(r#""kick" s ( . ) ( ) 2 pcycle"#, &ctx).unwrap();
if iter % 2 == 0 { if iter % 2 == 0 {
assert_eq!(outputs.len(), 1, "iter={}: emit should be picked", iter); assert_eq!(outputs.len(), 1, "iter={}: emit should be picked", iter);
} else { } else {
@@ -128,7 +128,7 @@ fn cycle_with_sounds() {
let ctx = ctx_with(|c| c.runs = runs); let ctx = ctx_with(|c| c.runs = runs);
let f = forth(); let f = forth();
let outputs = f.evaluate( let outputs = f.evaluate(
r#"{ "kick" s . } { "hat" s . } { "snare" s . } 3 cycle"#, r#"( "kick" s . ) ( "hat" s . ) ( "snare" s . ) 3 cycle"#,
&ctx &ctx
).unwrap(); ).unwrap();
assert_eq!(outputs.len(), 1, "runs={}: expected 1 output", runs); assert_eq!(outputs.len(), 1, "runs={}: expected 1 output", runs);
@@ -346,7 +346,7 @@ fn every_offset_fires_at_offset() {
for iter in 0..8 { for iter in 0..8 {
let ctx = ctx_with(|c| c.iter = iter); let ctx = ctx_with(|c| c.iter = iter);
let f = forth(); let f = forth();
let outputs = f.evaluate(r#""kick" s { . } 4 2 every+"#, &ctx).unwrap(); let outputs = f.evaluate(r#""kick" s ( . ) 4 2 every+"#, &ctx).unwrap();
if iter % 4 == 2 { if iter % 4 == 2 {
assert_eq!(outputs.len(), 1, "iter={}: should fire", iter); assert_eq!(outputs.len(), 1, "iter={}: should fire", iter);
} else { } else {
@@ -361,7 +361,7 @@ fn every_offset_wraps_large_offset() {
for iter in 0..8 { for iter in 0..8 {
let ctx = ctx_with(|c| c.iter = iter); let ctx = ctx_with(|c| c.iter = iter);
let f = forth(); let f = forth();
let outputs = f.evaluate(r#""kick" s { . } 4 6 every+"#, &ctx).unwrap(); let outputs = f.evaluate(r#""kick" s ( . ) 4 6 every+"#, &ctx).unwrap();
if iter % 4 == 2 { if iter % 4 == 2 {
assert_eq!(outputs.len(), 1, "iter={}: should fire (wrapped offset)", iter); assert_eq!(outputs.len(), 1, "iter={}: should fire (wrapped offset)", iter);
} else { } else {
@@ -375,7 +375,7 @@ fn except_offset_inverse() {
for iter in 0..8 { for iter in 0..8 {
let ctx = ctx_with(|c| c.iter = iter); let ctx = ctx_with(|c| c.iter = iter);
let f = forth(); let f = forth();
let outputs = f.evaluate(r#""kick" s { . } 4 2 except+"#, &ctx).unwrap(); let outputs = f.evaluate(r#""kick" s ( . ) 4 2 except+"#, &ctx).unwrap();
if iter % 4 != 2 { if iter % 4 != 2 {
assert_eq!(outputs.len(), 1, "iter={}: should fire", iter); assert_eq!(outputs.len(), 1, "iter={}: should fire", iter);
} else { } else {
@@ -389,8 +389,8 @@ fn every_offset_zero_is_same_as_every() {
for iter in 0..8 { for iter in 0..8 {
let ctx = ctx_with(|c| c.iter = iter); let ctx = ctx_with(|c| c.iter = iter);
let f = forth(); let f = forth();
let a = f.evaluate(r#""kick" s { . } 3 every"#, &ctx).unwrap(); let a = f.evaluate(r#""kick" s ( . ) 3 every"#, &ctx).unwrap();
let b = f.evaluate(r#""kick" s { . } 3 0 every+"#, &ctx).unwrap(); let b = f.evaluate(r#""kick" s ( . ) 3 0 every+"#, &ctx).unwrap();
assert_eq!(a.len(), b.len(), "iter={}: every and every+ 0 should match", iter); assert_eq!(a.len(), b.len(), "iter={}: every and every+ 0 should match", iter);
} }
} }