3 Commits

Author SHA1 Message Date
5385bf675a Feat: fixing ratatui big-text and UX
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-16 15:43:22 +01:00
211e71f5a9 Feat: UI / UX 2026-02-16 01:22:40 +01:00
23c7abb145 Feat: improving MIDI 2026-02-15 19:06:49 +01:00
43 changed files with 1802 additions and 240 deletions

View File

@@ -30,7 +30,7 @@ path = "src/main.rs"
[[bin]] [[bin]]
name = "cagire-desktop" name = "cagire-desktop"
path = "src/bin/desktop.rs" path = "src/bin/desktop/main.rs"
required-features = ["desktop"] required-features = ["desktop"]
[features] [features]
@@ -41,6 +41,7 @@ desktop = [
"dep:eframe", "dep:eframe",
"dep:egui_ratatui", "dep:egui_ratatui",
"dep:soft_ratatui", "dep:soft_ratatui",
"dep:rustc-hash",
"dep:image", "dep:image",
] ]
@@ -77,6 +78,7 @@ egui = { version = "0.33", optional = true }
eframe = { version = "0.33", optional = true } eframe = { version = "0.33", optional = true }
egui_ratatui = { version = "2.1", optional = true } egui_ratatui = { version = "2.1", optional = true }
soft_ratatui = { version = "0.1.3", features = ["unicodefonts"], optional = true } soft_ratatui = { version = "0.1.3", features = ["unicodefonts"], optional = true }
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 }

View File

@@ -160,6 +160,12 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result<Vec<Op>, String> {
ops.push(Op::Branch(else_ops.len())); ops.push(Op::Branch(else_ops.len()));
ops.extend(else_ops); ops.extend(else_ops);
} }
} else if word == "case" {
let (case_ops, consumed) = compile_case(&tokens[i + 1..], dict)?;
i += consumed;
ops.extend(case_ops);
} else if word == "of" || word == "endof" || word == "endcase" {
return Err(format!("unexpected '{word}'"));
} else if !compile_word(word, Some(*span), &mut ops, dict) { } else if !compile_word(word, Some(*span), &mut ops, dict) {
return Err(format!("unknown word: {word}")); return Err(format!("unknown word: {word}"));
} }
@@ -300,3 +306,69 @@ fn compile_if(
Ok((then_ops, else_ops, then_pos + 1, then_span, else_span)) Ok((then_ops, else_ops, then_pos + 1, then_span, else_span))
} }
fn compile_case(tokens: &[Token], dict: &Dictionary) -> Result<(Vec<Op>, usize), String> {
let mut depth = 1;
let mut endcase_pos = None;
let mut clauses: Vec<(usize, usize)> = Vec::new();
let mut last_of = None;
for (i, tok) in tokens.iter().enumerate() {
if let Token::Word(w, _) = tok {
match w.as_str() {
"case" => depth += 1,
"endcase" => {
depth -= 1;
if depth == 0 {
endcase_pos = Some(i);
break;
}
}
"of" if depth == 1 => last_of = Some(i),
"endof" if depth == 1 => {
let of_pos = last_of.ok_or("'endof' without matching 'of'")?;
clauses.push((of_pos, i));
last_of = None;
}
_ => {}
}
}
}
let endcase_pos = endcase_pos.ok_or("missing 'endcase'")?;
let mut ops = Vec::new();
let mut branch_fixups: Vec<usize> = Vec::new();
let mut clause_start = 0;
for &(of_pos, endof_pos) in &clauses {
let test_ops = compile(&tokens[clause_start..of_pos], dict)?;
let body_ops = compile(&tokens[of_pos + 1..endof_pos], dict)?;
ops.extend(test_ops);
ops.push(Op::Over);
ops.push(Op::Eq);
ops.push(Op::BranchIfZero(body_ops.len() + 2, None, None));
ops.push(Op::Drop);
ops.extend(body_ops);
branch_fixups.push(ops.len());
ops.push(Op::Branch(0));
clause_start = endof_pos + 1;
}
let default_tokens = &tokens[clause_start..endcase_pos];
if !default_tokens.is_empty() {
let default_ops = compile(default_tokens, dict)?;
ops.extend(default_ops);
}
ops.push(Op::Drop);
let end = ops.len();
for pos in branch_fixups {
ops[pos] = Op::Branch(end - pos - 1);
}
Ok((ops, endcase_pos + 1))
}

View File

@@ -64,6 +64,7 @@ pub enum Op {
Emit, Emit,
Get, Get,
Set, Set,
SetKeep,
GetContext(&'static str), GetContext(&'static str),
Rand(Option<SourceSpan>), Rand(Option<SourceSpan>),
ExpRand(Option<SourceSpan>), ExpRand(Option<SourceSpan>),

View File

@@ -637,6 +637,16 @@ impl Forth {
.expect("var_writes taken") .expect("var_writes taken")
.insert(name, val); .insert(name, val);
} }
Op::SetKeep => {
let name = pop(stack)?;
let name = name.as_str()?.to_string();
let val = stack.last().ok_or("Stack underflow")?.clone();
var_writes_cell
.borrow_mut()
.as_mut()
.expect("var_writes taken")
.insert(name, val);
}
Op::GetContext(name) => { Op::GetContext(name) => {
let val = match *name { let val = match *name {
@@ -1231,49 +1241,117 @@ impl Forth {
// MIDI operations // MIDI operations
Op::MidiEmit => { Op::MidiEmit => {
let (_, params) = cmd.snapshot().unwrap_or((None, &[])); let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
// Build schedule: (emit_idx, delta_secs) — same logic as Op::Emit
let schedule: Vec<(usize, f64)> = if has_arp_list(cmd) {
let arp_count = compute_arp_count(cmd);
let explicit = !cmd.deltas().is_empty();
let delta_list = cmd.deltas();
let count = if explicit {
arp_count.max(delta_list.len())
} else {
arp_count
};
(0..count)
.map(|i| {
let delta_secs = if explicit {
let frac = delta_list[i % delta_list.len()]
.as_float()
.unwrap_or(0.0);
ctx.nudge_secs + frac * ctx.step_duration()
} else {
ctx.nudge_secs
+ (i as f64 / count as f64) * ctx.step_duration()
};
(i, delta_secs)
})
.collect()
} else {
let poly_count = compute_poly_count(cmd);
let deltas: Vec<f64> = if cmd.deltas().is_empty() {
vec![0.0]
} else {
cmd.deltas()
.iter()
.filter_map(|v| v.as_float().ok())
.collect()
};
let mut sched = Vec::with_capacity(poly_count * deltas.len());
for poly_idx in 0..poly_count {
for &frac in &deltas {
sched.push((
poly_idx,
ctx.nudge_secs + frac * ctx.step_duration(),
));
}
}
sched
};
for (emit_idx, delta_secs) in schedule {
let get_int = |name: &str| -> Option<i64> { let get_int = |name: &str| -> Option<i64> {
params params
.iter() .iter()
.rev() .rev()
.find(|(k, _)| *k == name) .find(|(k, _)| *k == name)
.and_then(|(_, v)| v.as_int().ok()) .and_then(|(_, v)| {
resolve_cycling(v, emit_idx).as_int().ok()
})
}; };
let get_float = |name: &str| -> Option<f64> { let get_float = |name: &str| -> Option<f64> {
params params
.iter() .iter()
.rev() .rev()
.find(|(k, _)| *k == name) .find(|(k, _)| *k == name)
.and_then(|(_, v)| v.as_float().ok()) .and_then(|(_, v)| {
resolve_cycling(v, emit_idx).as_float().ok()
})
}; };
let chan = get_int("chan") let chan = get_int("chan")
.map(|c| (c.clamp(1, 16) - 1) as u8) .map(|c| (c.clamp(1, 16) - 1) as u8)
.unwrap_or(0); .unwrap_or(0);
let dev = get_int("dev").map(|d| d.clamp(0, 3) as u8).unwrap_or(0); let dev =
get_int("dev").map(|d| d.clamp(0, 3) as u8).unwrap_or(0);
let delta_suffix = if delta_secs > 0.0 {
format!("/delta/{delta_secs}")
} else {
String::new()
};
if let (Some(cc), Some(val)) = (get_int("ccnum"), get_int("ccout")) { if let (Some(cc), Some(val)) = (get_int("ccnum"), get_int("ccout")) {
let cc = cc.clamp(0, 127) as u8; let cc = cc.clamp(0, 127) as u8;
let val = val.clamp(0, 127) as u8; let val = val.clamp(0, 127) as u8;
outputs.push(format!("/midi/cc/{cc}/{val}/chan/{chan}/dev/{dev}")); outputs.push(format!(
"/midi/cc/{cc}/{val}/chan/{chan}/dev/{dev}{delta_suffix}"
));
} else if let Some(bend) = get_float("bend") { } else if let Some(bend) = get_float("bend") {
let bend_clamped = bend.clamp(-1.0, 1.0); let bend_clamped = bend.clamp(-1.0, 1.0);
let bend_14bit = ((bend_clamped + 1.0) * 8191.5) as u16; let bend_14bit = ((bend_clamped + 1.0) * 8191.5) as u16;
outputs.push(format!("/midi/bend/{bend_14bit}/chan/{chan}/dev/{dev}")); outputs.push(format!(
"/midi/bend/{bend_14bit}/chan/{chan}/dev/{dev}{delta_suffix}"
));
} else if let Some(pressure) = get_int("pressure") { } else if let Some(pressure) = get_int("pressure") {
let pressure = pressure.clamp(0, 127) as u8; let pressure = pressure.clamp(0, 127) as u8;
outputs.push(format!("/midi/pressure/{pressure}/chan/{chan}/dev/{dev}")); outputs.push(format!(
"/midi/pressure/{pressure}/chan/{chan}/dev/{dev}{delta_suffix}"
));
} else if let Some(program) = get_int("program") { } else if let Some(program) = get_int("program") {
let program = program.clamp(0, 127) as u8; let program = program.clamp(0, 127) as u8;
outputs.push(format!("/midi/program/{program}/chan/{chan}/dev/{dev}")); outputs.push(format!(
"/midi/program/{program}/chan/{chan}/dev/{dev}{delta_suffix}"
));
} else { } else {
let note = get_int("note").unwrap_or(60).clamp(0, 127) as u8; let note = get_int("note").unwrap_or(60).clamp(0, 127) as u8;
let velocity = get_int("velocity").unwrap_or(100).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 = get_float("dur").unwrap_or(1.0);
let dur_secs = dur * ctx.step_duration(); let dur_secs = dur * ctx.step_duration();
outputs.push(format!( outputs.push(format!(
"/midi/note/{note}/vel/{velocity}/chan/{chan}/dur/{dur_secs}/dev/{dev}" "/midi/note/{note}/vel/{velocity}/chan/{chan}/dur/{dur_secs}/dev/{dev}{delta_suffix}"
)); ));
} }
} }
}
Op::MidiClock => { Op::MidiClock => {
let (_, params) = cmd.snapshot().unwrap_or((None, &[])); let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
let dev = extract_dev_param(params); let dev = extract_dev_param(params);

View File

@@ -272,6 +272,14 @@ pub(crate) fn compile_word(
} }
} }
if let Some(var_name) = name.strip_prefix(',') {
if !var_name.is_empty() {
ops.push(Op::PushStr(Arc::from(var_name), span));
ops.push(Op::SetKeep);
return true;
}
}
if let Some(midi) = parse_note_name(name) { if let Some(midi) = parse_note_name(name) {
ops.push(Op::PushInt(midi, span)); ops.push(Op::PushInt(midi, span));
return true; return true;

View File

@@ -558,6 +558,16 @@ pub(super) const WORDS: &[Word] = &[
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },
Word {
name: ",<var>",
aliases: &[],
category: "Variables",
stack: "(val -- val)",
desc: "Store value in variable, keep on stack",
example: "440 ,freq => 440",
compile: Simple,
varargs: false,
},
// Definitions // Definitions
Word { Word {
name: ":", name: ":",

View File

@@ -3,5 +3,5 @@ mod parser;
mod theme; mod theme;
pub use highlighter::{CodeHighlighter, NoHighlight}; pub use highlighter::{CodeHighlighter, NoHighlight};
pub use parser::parse; pub use parser::{parse, CodeBlock, ParsedMarkdown};
pub use theme::{DefaultTheme, MarkdownTheme}; pub use theme::{DefaultTheme, MarkdownTheme};

View File

@@ -5,17 +5,31 @@ use ratatui::text::{Line as RLine, Span};
use crate::highlighter::CodeHighlighter; use crate::highlighter::CodeHighlighter;
use crate::theme::MarkdownTheme; use crate::theme::MarkdownTheme;
pub struct CodeBlock {
pub start_line: usize,
pub end_line: usize,
pub source: String,
}
pub struct ParsedMarkdown {
pub lines: Vec<RLine<'static>>,
pub code_blocks: Vec<CodeBlock>,
}
pub fn parse<T: MarkdownTheme, H: CodeHighlighter>( pub fn parse<T: MarkdownTheme, H: CodeHighlighter>(
md: &str, md: &str,
theme: &T, theme: &T,
highlighter: &H, highlighter: &H,
) -> Vec<RLine<'static>> { ) -> ParsedMarkdown {
let processed = preprocess_markdown(md); let processed = preprocess_markdown(md);
let text = minimad::Text::from(processed.as_str()); let text = minimad::Text::from(processed.as_str());
let mut lines = Vec::new(); let mut lines = Vec::new();
let mut code_line_nr: usize = 0; let mut code_line_nr: usize = 0;
let mut table_buffer: Vec<TableRow> = Vec::new(); let mut table_buffer: Vec<TableRow> = Vec::new();
let mut code_blocks: Vec<CodeBlock> = Vec::new();
let mut current_block_start: Option<usize> = None;
let mut current_block_source: Vec<String> = Vec::new();
let flush_table = |buf: &mut Vec<TableRow>, out: &mut Vec<RLine<'static>>, theme: &T| { let flush_table = |buf: &mut Vec<TableRow>, out: &mut Vec<RLine<'static>>, theme: &T| {
if buf.is_empty() { if buf.is_empty() {
@@ -27,16 +41,43 @@ pub fn parse<T: MarkdownTheme, H: CodeHighlighter>(
} }
}; };
let close_block = |start: Option<usize>,
source: &mut Vec<String>,
blocks: &mut Vec<CodeBlock>,
lines: &Vec<RLine<'static>>| {
if let Some(start) = start {
blocks.push(CodeBlock {
start_line: start,
end_line: lines.len(),
source: std::mem::take(source).join("\n"),
});
}
};
for line in text.lines { for line in text.lines {
let is_code = matches!(&line, Line::Normal(c) if c.style == CompositeStyle::Code);
if !is_code {
close_block(
current_block_start.take(),
&mut current_block_source,
&mut code_blocks,
&lines,
);
}
match line { match line {
Line::Normal(composite) if composite.style == CompositeStyle::Code => { Line::Normal(composite) if composite.style == CompositeStyle::Code => {
flush_table(&mut table_buffer, &mut lines, theme); flush_table(&mut table_buffer, &mut lines, theme);
code_line_nr += 1; code_line_nr += 1;
if current_block_start.is_none() {
current_block_start = Some(lines.len());
}
let raw: String = composite let raw: String = composite
.compounds .compounds
.iter() .iter()
.map(|c: &minimad::Compound| c.src) .map(|c: &minimad::Compound| c.src)
.collect(); .collect();
current_block_source.push(raw.clone());
let mut spans = vec![ let mut spans = vec![
Span::styled(format!(" {code_line_nr:>2} "), theme.code_border()), Span::styled(format!(" {code_line_nr:>2} "), theme.code_border()),
Span::styled("", theme.code_border()), Span::styled("", theme.code_border()),
@@ -66,9 +107,15 @@ pub fn parse<T: MarkdownTheme, H: CodeHighlighter>(
} }
} }
} }
close_block(
current_block_start.take(),
&mut current_block_source,
&mut code_blocks,
&lines,
);
flush_table(&mut table_buffer, &mut lines, theme); flush_table(&mut table_buffer, &mut lines, theme);
lines ParsedMarkdown { lines, code_blocks }
} }
pub fn preprocess_markdown(md: &str) -> String { pub fn preprocess_markdown(md: &str) -> String {
@@ -300,28 +347,39 @@ mod tests {
#[test] #[test]
fn test_parse_headings() { fn test_parse_headings() {
let md = "# H1\n## H2\n### H3"; let md = "# H1\n## H2\n### H3";
let lines = parse(md, &DefaultTheme, &NoHighlight); let parsed = parse(md, &DefaultTheme, &NoHighlight);
assert_eq!(lines.len(), 3); assert_eq!(parsed.lines.len(), 3);
} }
#[test] #[test]
fn test_parse_code_block() { fn test_parse_code_block() {
let md = "```\ncode line\n```"; let md = "```\ncode line\n```";
let lines = parse(md, &DefaultTheme, &NoHighlight); let parsed = parse(md, &DefaultTheme, &NoHighlight);
assert!(!lines.is_empty()); assert!(!parsed.lines.is_empty());
assert_eq!(parsed.code_blocks.len(), 1);
assert_eq!(parsed.code_blocks[0].source, "code line");
} }
#[test] #[test]
fn test_parse_table() { fn test_parse_table() {
let md = "| A | B |\n|---|---|\n| 1 | 2 |"; let md = "| A | B |\n|---|---|\n| 1 | 2 |";
let lines = parse(md, &DefaultTheme, &NoHighlight); let parsed = parse(md, &DefaultTheme, &NoHighlight);
assert_eq!(lines.len(), 2); assert_eq!(parsed.lines.len(), 2);
} }
#[test] #[test]
fn test_default_theme_works() { fn test_default_theme_works() {
let md = "Hello **world**"; let md = "Hello **world**";
let lines = parse(md, &DefaultTheme, &NoHighlight); let parsed = parse(md, &DefaultTheme, &NoHighlight);
assert_eq!(lines.len(), 1); assert_eq!(parsed.lines.len(), 1);
}
#[test]
fn test_multiple_code_blocks() {
let md = "text\n```\nfirst\n```\nmore text\n```\nsecond line 1\nsecond line 2\n```";
let parsed = parse(md, &DefaultTheme, &NoHighlight);
assert_eq!(parsed.code_blocks.len(), 2);
assert_eq!(parsed.code_blocks[0].source, "first");
assert_eq!(parsed.code_blocks[1].source, "second line 1\nsecond line 2");
} }
} }

185
docs/control_flow.md Normal file
View File

@@ -0,0 +1,185 @@
# Control Flow
A drum pattern that plays the same sound on every step is not very interesting. You want kicks on the downbeats, snares on the backbeats, hats filling the gaps. Control flow is how you make decisions inside a step.
## if / else / then
The simplest branch. Push a condition, then `if`:
```forth
step 4 mod 0 = if kick s . then
```
Every fourth step gets a kick. The rest do nothing. Add `else` for a two-way split:
```forth
step 2 mod 0 = if
kick s 0.8 gain .
else
hat s 0.3 gain .
then
```
These are compiler syntax -- you won't find them in the dictionary. Think nothing of it.
## ? and !?
When you already have a quotation, `?` executes it if the condition is truthy:
```forth
{ snare s . } coin ?
```
`!?` is the opposite -- executes when falsy:
```forth
{ hat s 0.2 gain . } coin !?
```
These pair well with `chance`, `prob`, and the other probability words:
```forth
{ rim s . } fill ? ;; rim only during fills
{ 0.5 verb } 0.3 chance ? ;; occasional reverb wash
```
## ifelse
Two quotations, one condition. The true branch comes first:
```forth
{ kick s . } { hat s . } step 2 mod 0 = ifelse
```
Reads naturally: "kick or hat, depending on whether it's an even step."
```forth
{ c3 note } { c4 note } coin ifelse
saw s 0.6 gain . ;; bass or lead, coin flip
```
## pick
Choose the nth option from a list of quotations:
```forth
{ kick s . } { snare s . } { hat s . } step 3 mod pick
```
Step 0 plays kick, step 1 plays snare, step 2 plays hat. The index is 0-based.
```forth
{ c4 } { e4 } { g4 } { b4 } step 4 mod pick
note sine s 0.5 decay .
```
Four notes cycling through a major seventh chord, one per step.
## case / of / endof / endcase
For matching a value against several options. Cleaner than a chain of `if`s when you have more than two branches:
```forth
step 8 mod case
0 of kick s . endof
4 of snare s . endof
endcase
```
Steps 0 and 4 get sounds. Everything else falls through to `endcase` and nothing happens.
A fuller pattern:
```forth
step 8 mod case
0 of kick s 0.9 gain . endof
2 of hat s 0.3 gain . endof
4 of snare s 0.7 gain . endof
6 of hat s 0.3 gain . endof
hat s 0.15 gain .
endcase
```
The last line before `endcase` is the default -- it runs when no `of` matched. Here it gives unmatched steps a ghost hat.
The `of` value can be any expression:
```forth
step 16 mod case
0 of kick s . endof
3 1 + of snare s . endof
2 4 * of kick s . snare s . endof
endcase
```
## times
Repeat a quotation n times. `@i` holds the current iteration (starting from 0):
```forth
4 { @i 4 / at hat s . } times ;; four hats, evenly spaced
```
Build chords:
```forth
3 { c4 @i 4 * + note } times
sine s 0.4 gain 0.5 verb . ;; c4, e4, g#4
```
Subdivide and accent:
```forth
8 {
@i 8 / at
@i 4 mod 0 = if 0.7 else 0.2 then gain
hat s .
} times
```
Eight hats per step. Every fourth one louder.
## Putting It Together
A basic drum pattern using `case`:
```forth
step 8 mod case
0 of kick s . endof
2 of { hat s . } often endof
4 of snare s . endof
6 of { rim s . } sometimes endof
{ hat s 0.15 gain . } coin ?
endcase
```
Kicks and snares on the strong beats. Hats and rims show up probabilistically. The default sprinkles ghost hats.
A melodic step that picks a scale degree and adds micro-timing:
```forth
{ c4 } { d4 } { e4 } { g4 } { a4 } step 5 mod pick
note
step 3 mod 0 = if
0 0.33 0.66 at ;; triplet feel on every third step
then
saw s 0.4 gain 0.3 decay 0.2 verb .
```
A `times` loop paired with `case` for a drum machine in one step:
```forth
4 {
@i case
0 of kick s . endof
1 of hat s 0.3 gain . endof
2 of snare s . endof
3 of { rim s . } 0.5 chance endof
endcase
@i 4 / at
} times
```
Four voices, four sub-positions, one step.

View File

@@ -89,9 +89,10 @@ Cagire uses prefix syntax:
```forth ```forth
10 !x ;; store 10 in x 10 !x ;; store 10 in x
@x ;; fetch x (returns 0 if undefined) @x ;; fetch x (returns 0 if undefined)
10 ,x ;; store 10 in x, keep on stack
``` ```
No declaration needed. Variables spring into existence when you store to them. No declaration needed. Variables spring into existence when you store to them. `,x` stores and keeps the value on the stack.
## Floating Point ## Floating Point

100
docs/tutorial_at.md Normal file
View File

@@ -0,0 +1,100 @@
# Timing with at
Every step has a duration. By default, sounds emit at the very start of that duration. `at` changes *when* within the step sounds fire -- giving you sub-step rhythmic control without adding more steps.
## The Basics
`at` drains the entire stack and stores the values as timing offsets. Each value is a fraction of the step duration: 0 = start, 0.5 = halfway, 1.0 = next step boundary.
```forth
0.5 at kick s . ;; kick at the midpoint
```
Push multiple values before calling `at` to get multiple emits from a single `.`:
```forth
0 0.5 at kick s . ;; two kicks: one at start, one at midpoint
0 0.25 0.5 0.75 at hat s . ;; four hats, evenly spaced
```
The deltas persist across multiple `.` calls until `clear` or a new `at`:
```forth
0 0.5 at
kick s . ;; 2 kicks
hat s . ;; 2 hats (same timing)
clear
snare s . ;; 1 snare (deltas cleared)
```
## Cross-product: at Without arp
Without `arp`, deltas multiply with polyphonic voices. If you have 3 notes and 2 deltas, you get 6 emits -- every note at every delta:
```forth
0 0.5 at
c4 e4 g4 note sine s . ;; 6 emits: 3 notes x 2 deltas
```
This is a chord played twice per step.
## 1:1 Pairing: at With arp
`arp` changes the behavior. Instead of cross-product, deltas and arp values pair up 1:1. Each delta gets one note from the arpeggio:
```forth
0 0.33 0.66 at
c4 e4 g4 arp note sine s . ;; c4 at 0, e4 at 0.33, g4 at 0.66
```
If the lists differ in length, the shorter one wraps around:
```forth
0 0.25 0.5 0.75 at
c4 e4 arp note sine s . ;; c4, e4, c4, e4 at 4 time points
```
This is THE key distinction. Without `arp`: every note at every time. With `arp`: one note per time slot.
## Generating Deltas
You rarely type deltas by hand. Use generators:
Evenly spaced via `.,`:
```forth
0 1 0.25 ., at hat s . ;; 0 0.25 0.5 0.75 1.0
```
Euclidean distribution via `euclid`:
```forth
3 8 euclid at hat s . ;; 3 hats at positions 0, 3, 5
```
Random timing via `gen`:
```forth
{ 0.0 1.0 rand } 4 gen at hat s . ;; 4 hats at random positions
```
Geometric spacing via `geom..`:
```forth
0.0 2.0 4 geom.. at hat s . ;; exponentially spaced
```
## Gating at
Wrap `at` expressions in quotations for conditional timing:
```forth
{ 0 0.25 0.5 0.75 at } 2 every ;; 16th-note hats every other bar
hat s .
{ 0 0.5 at } 0.5 chance ;; 50% chance of double-hit
kick s .
```
When the quotation doesn't execute, no deltas are set -- you get the default single emit at beat start.

View File

@@ -27,13 +27,13 @@ Sequences of values drive music: arpeggios, parameter sweeps, rhythmic patterns.
100 0.5 4 geom.. ;; 100 50 25 12.5 100 0.5 4 geom.. ;; 100 50 25 12.5
``` ```
Musical use -- build a harmonic series: Build a harmonic series:
```forth ```forth
110 2 5 geom.. 5 rev note 110 2 5 geom.. 5 rev freq
``` ```
That gives you 110, 220, 440, 880, 1760 (reversed), ready to feed into `note` or `freq`. That gives you 110, 220, 440, 880, 1760 (reversed), ready to feed into `freq`.
## Computed Sequences ## Computed Sequences
@@ -73,14 +73,6 @@ The distinction: `gen` is for building data. `times` is for doing things.
These give you raw indices as data on the stack. This is different from `bjork` and `pbjork` (covered in the Randomness tutorial), which execute a quotation on matching steps. `euclid` gives you numbers to work with; `bjork` triggers actions. These give you raw indices as data on the stack. This is different from `bjork` and `pbjork` (covered in the Randomness tutorial), which execute a quotation on matching steps. `euclid` gives you numbers to work with; `bjork` triggers actions.
Use euclid indices to pick notes from a scale:
```forth
: pick ( ..vals n i -- val ) rot drop swap ;
c4 d4 e4 g4 a4 ;; pentatonic scale on the stack
3 8 euclid ;; get 3 hit positions
```
## Transforming Sequences ## Transforming Sequences
Four words reshape values already on the stack. All take n (the count of items to operate on) from the top: Four words reshape values already on the stack. All take n (the count of items to operate on) from the top:
@@ -143,39 +135,4 @@ Or replicate a value for batch processing:
0.5 4 dupn 4 sum ;; 2.0 0.5 4 dupn 4 sum ;; 2.0
``` ```
## Combining Techniques
An arpeggio that shuffles every time the step plays:
```forth
c4 e4 g4 b4 4 shuffle
drop drop drop ;; keep only the first note
note sine s .
```
Parameter spread across voices -- four sines with geometrically spaced frequencies:
```forth
220 1.5 4 geom..
4 { @i 1 + pick note sine s . } times
```
Euclidean rhythm driving note selection from a generated sequence:
```forth
3 8 euclid ;; 3 hit indices
```
A chord built from a range, then sorted high to low:
```forth
60 67 .. 8 rsort
```
Rhythmic density control -- generate hits, keep only the loud ones:
```forth
{ 0.0 1.0 rand } 8 gen
```
The generator words produce raw material. The transform words shape it. Together they let you express complex musical ideas in a few words. The generator words produce raw material. The transform words shape it. Together they let you express complex musical ideas in a few words.

View File

@@ -0,0 +1,71 @@
# Using Variables
Variables let you name values and share data between steps. They are global -- any step can read what another step wrote.
## Store and Fetch
`!name` stores the top of the stack into a variable. `@name` fetches it back. Variables spring into existence when you first store to them. Fetching a variable that was never stored returns 0.
```forth
10 !x ;; store 10 in x
@x ;; pushes 10
@y ;; pushes 0 (never stored)
```
## Store and Keep
`,name` stores just like `!name` but keeps the value on the stack. Useful when you want to name something and keep using it:
```forth
440 ,freq sine s . ;; stores 440 in freq AND passes it to the pipeline
```
Without `,`, you'd need `dup`:
```forth
440 dup !freq sine s . ;; equivalent, but noisier
```
## Sharing Between Steps
Variables are shared across all steps. One step can store a value that another reads:
```forth
;; step 0: pick a root note
c4 iter 7 mod + !root
;; step 4: read it
@root 7 + note sine s .
```
Every time the pattern loops, step 0 picks a new root. Step 4 always harmonizes with it.
## Accumulators
Fetch, modify, store back. A classic pattern for evolving values:
```forth
@n 1 + !n ;; increment n each time this step runs
@n 12 mod note sine s . ;; cycle through 12 notes
```
Reset on some condition:
```forth
@n 1 + !n
{ 0 !n } @n 16 > ? ;; reset after 16
```
## Naming Sounds
Store a sound name in a variable, reuse it across steps:
```forth
;; step 0: choose the sound
"sine" !synth
;; step 1, 2, 3...
c4 note @synth s .
```
Change one step, all steps follow.

View File

@@ -134,6 +134,7 @@ impl App {
color_scheme: self.ui.color_scheme, color_scheme: self.ui.color_scheme,
layout: self.audio.config.layout, layout: self.audio.config.layout,
hue_rotation: self.ui.hue_rotation, hue_rotation: self.ui.hue_rotation,
onboarding_dismissed: self.ui.onboarding_dismissed.clone(),
..Default::default() ..Default::default()
}, },
link: crate::settings::LinkSettings { link: crate::settings::LinkSettings {
@@ -1117,6 +1118,17 @@ impl App {
UndoEntry { scope: reverse_scope, cursor } UndoEntry { scope: reverse_scope, cursor }
} }
pub fn maybe_show_onboarding(&mut self) {
if self.ui.modal != Modal::None {
return;
}
let name = self.page.name();
if self.ui.onboarding_dismissed.iter().any(|d| d == name) {
return;
}
self.ui.modal = Modal::Onboarding { page: 0 };
}
pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) { pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) {
// Handle undo/redo before the undoable snapshot // Handle undo/redo before the undoable snapshot
match cmd { match cmd {
@@ -1342,11 +1354,26 @@ impl App {
} }
// Page navigation // Page navigation
AppCommand::PageLeft => self.page.left(), AppCommand::PageLeft => {
AppCommand::PageRight => self.page.right(), self.page.left();
AppCommand::PageUp => self.page.up(), self.maybe_show_onboarding();
AppCommand::PageDown => self.page.down(), }
AppCommand::GoToPage(page) => self.page = page, AppCommand::PageRight => {
self.page.right();
self.maybe_show_onboarding();
}
AppCommand::PageUp => {
self.page.up();
self.maybe_show_onboarding();
}
AppCommand::PageDown => {
self.page.down();
self.maybe_show_onboarding();
}
AppCommand::GoToPage(page) => {
self.page = page;
self.maybe_show_onboarding();
}
// Help navigation // Help navigation
AppCommand::HelpToggleFocus => help_nav::toggle_focus(&mut self.ui), AppCommand::HelpToggleFocus => help_nav::toggle_focus(&mut self.ui),
@@ -1391,9 +1418,11 @@ impl App {
self.select_edit_bank(bank); self.select_edit_bank(bank);
self.select_edit_pattern(pattern); self.select_edit_pattern(pattern);
self.page.down(); self.page.down();
self.maybe_show_onboarding();
} }
AppCommand::PatternsBack => { AppCommand::PatternsBack => {
self.page.down(); self.page.down();
self.maybe_show_onboarding();
} }
// Mute/Solo (staged) // Mute/Solo (staged)
@@ -1418,6 +1447,7 @@ impl App {
} }
AppCommand::HideTitle => { AppCommand::HideTitle => {
self.ui.show_title = false; self.ui.show_title = false;
self.maybe_show_onboarding();
} }
AppCommand::ToggleEditorStack => { AppCommand::ToggleEditorStack => {
self.editor_ctx.show_stack = !self.editor_ctx.show_stack; self.editor_ctx.show_stack = !self.editor_ctx.show_stack;
@@ -1612,6 +1642,22 @@ impl App {
); );
} }
// Onboarding
AppCommand::DismissOnboarding => {
let name = self.page.name().to_string();
if !self.ui.onboarding_dismissed.contains(&name) {
self.ui.onboarding_dismissed.push(name);
}
}
AppCommand::ResetOnboarding => {
self.ui.onboarding_dismissed.clear();
}
AppCommand::GoToHelpTopic(topic) => {
self.ui.modal = Modal::None;
self.page = Page::Help;
self.ui.help_topic = topic;
}
// Prelude // Prelude
AppCommand::OpenPreludeEditor => self.open_prelude_editor(), AppCommand::OpenPreludeEditor => self.open_prelude_editor(),
AppCommand::SavePrelude => self.save_prelude(), AppCommand::SavePrelude => self.save_prelude(),

View File

@@ -0,0 +1,267 @@
//! Programmatic rendering of Unicode block elements for the desktop backend.
//!
//! Real terminals render block characters (█, ▀, ▄, quadrants, sextants) as
//! pixel-perfect filled rectangles. The bitmap font backend can't guarantee
//! gap-free fills and lacks sextant glyphs entirely. This wrapper intercepts
//! block element code points and draws them directly on the pixmap, delegating
//! everything else to EmbeddedGraphics.
use ratatui::buffer::Cell;
use ratatui::style::{Color, Modifier};
use rustc_hash::FxHashSet;
use soft_ratatui::{EmbeddedGraphics, RasterBackend, RgbPixmap};
pub struct BlockCharBackend {
pub inner: EmbeddedGraphics,
}
impl RasterBackend for BlockCharBackend {
fn draw_cell(
&mut self,
x: u16,
y: u16,
cell: &Cell,
always_redraw_list: &mut FxHashSet<(u16, u16)>,
blinking_fast: bool,
blinking_slow: bool,
char_width: usize,
char_height: usize,
rgb_pixmap: &mut RgbPixmap,
) {
let cp = cell.symbol().chars().next().unwrap_or(' ') as u32;
if !is_block_element(cp) {
self.inner.draw_cell(
x,
y,
cell,
always_redraw_list,
blinking_fast,
blinking_slow,
char_width,
char_height,
rgb_pixmap,
);
return;
}
let (fg, bg) = resolve_colors(cell, always_redraw_list, x, y, blinking_fast, blinking_slow);
let px = x as usize * char_width;
let py = y as usize * char_height;
fill_rect(rgb_pixmap, px, py, char_width, char_height, bg);
draw_block_element(rgb_pixmap, cp, px, py, char_width, char_height, fg);
}
}
// ---------------------------------------------------------------------------
// Block element classification and drawing
// ---------------------------------------------------------------------------
fn is_block_element(cp: u32) -> bool {
matches!(cp, 0x2580..=0x2590 | 0x2594..=0x259F | 0x1FB00..=0x1FB3B)
}
fn draw_block_element(
pixmap: &mut RgbPixmap,
cp: u32,
px: usize,
py: usize,
cw: usize,
ch: usize,
fg: [u8; 3],
) {
match cp {
0x2580 => fill_rect(pixmap, px, py, cw, ch / 2, fg),
0x2581..=0x2587 => {
let n = (cp - 0x2580) as usize;
let h = ch * n / 8;
fill_rect(pixmap, px, py + ch - h, cw, h, fg);
}
0x2588 => fill_rect(pixmap, px, py, cw, ch, fg),
0x2589..=0x258F => {
let n = (0x2590 - cp) as usize;
fill_rect(pixmap, px, py, cw * n / 8, ch, fg);
}
0x2590 => {
let hw = cw / 2;
fill_rect(pixmap, px + hw, py, cw - hw, ch, fg);
}
0x2594 => fill_rect(pixmap, px, py, cw, (ch / 8).max(1), fg),
0x2595 => {
let w = (cw / 8).max(1);
fill_rect(pixmap, px + cw - w, py, w, ch, fg);
}
0x2596..=0x259F => draw_quadrants(pixmap, px, py, cw, ch, fg, cp),
0x1FB00..=0x1FB3B => draw_sextants(pixmap, px, py, cw, ch, fg, cp),
_ => unreachable!(),
}
}
// ---------------------------------------------------------------------------
// Quadrants (U+2596-U+259F): 2x2 grid
// ---------------------------------------------------------------------------
// Bits: 3=UL, 2=UR, 1=LL, 0=LR
const QUADRANT: [u8; 10] = [
0b0010, // ▖ LL
0b0001, // ▗ LR
0b1000, // ▘ UL
0b1011, // ▙ UL+LL+LR
0b1001, // ▚ UL+LR
0b1110, // ▛ UL+UR+LL
0b1101, // ▜ UL+UR+LR
0b0100, // ▝ UR
0b0110, // ▞ UR+LL
0b0111, // ▟ UR+LL+LR
];
fn draw_quadrants(
pixmap: &mut RgbPixmap,
px: usize,
py: usize,
cw: usize,
ch: usize,
fg: [u8; 3],
cp: u32,
) {
let pattern = QUADRANT[(cp - 0x2596) as usize];
let hw = cw / 2;
let hh = ch / 2;
let rw = cw - hw;
let rh = ch - hh;
if pattern & 0b1000 != 0 { fill_rect(pixmap, px, py, hw, hh, fg); }
if pattern & 0b0100 != 0 { fill_rect(pixmap, px + hw, py, rw, hh, fg); }
if pattern & 0b0010 != 0 { fill_rect(pixmap, px, py + hh, hw, rh, fg); }
if pattern & 0b0001 != 0 { fill_rect(pixmap, px + hw, py + hh, rw, rh, fg); }
}
// ---------------------------------------------------------------------------
// Sextants (U+1FB00-U+1FB3B): 2x3 grid
// ---------------------------------------------------------------------------
// Bit layout: 0=TL, 1=TR, 2=ML, 3=MR, 4=BL, 5=BR
// The 60 characters encode patterns 1-62, skipping 0 (space), 21 (left half),
// 42 (right half), and 63 (full block) which exist as standard block elements.
fn sextant_pattern(cp: u32) -> u8 {
let mut p = (cp - 0x1FB00) as u8 + 1;
if p >= 21 { p += 1; }
if p >= 42 { p += 1; }
p
}
fn draw_sextants(
pixmap: &mut RgbPixmap,
px: usize,
py: usize,
cw: usize,
ch: usize,
fg: [u8; 3],
cp: u32,
) {
let pattern = sextant_pattern(cp);
let hw = cw / 2;
let rw = cw - hw;
let h0 = ch / 3;
let h1 = (ch - h0) / 2;
let h2 = ch - h0 - h1;
let y1 = py + h0;
let y2 = y1 + h1;
if pattern & 0b000001 != 0 { fill_rect(pixmap, px, py, hw, h0, fg); }
if pattern & 0b000010 != 0 { fill_rect(pixmap, px + hw, py, rw, h0, fg); }
if pattern & 0b000100 != 0 { fill_rect(pixmap, px, y1, hw, h1, fg); }
if pattern & 0b001000 != 0 { fill_rect(pixmap, px + hw, y1, rw, h1, fg); }
if pattern & 0b010000 != 0 { fill_rect(pixmap, px, y2, hw, h2, fg); }
if pattern & 0b100000 != 0 { fill_rect(pixmap, px + hw, y2, rw, h2, fg); }
}
// ---------------------------------------------------------------------------
// Pixel operations
// ---------------------------------------------------------------------------
fn fill_rect(pixmap: &mut RgbPixmap, x0: usize, y0: usize, w: usize, h: usize, color: [u8; 3]) {
let pw = pixmap.width;
let x_end = (x0 + w).min(pw);
let y_end = (y0 + h).min(pixmap.height);
let data = &mut pixmap.data;
for y in y0..y_end {
let start = 3 * (y * pw + x0);
let end = 3 * (y * pw + x_end);
for chunk in data[start..end].chunks_exact_mut(3) {
chunk.copy_from_slice(&color);
}
}
}
// ---------------------------------------------------------------------------
// Color resolution (mirrors soft_ratatui::colors which is private)
// ---------------------------------------------------------------------------
fn resolve_colors(
cell: &Cell,
always_redraw_list: &mut FxHashSet<(u16, u16)>,
x: u16,
y: u16,
blinking_fast: bool,
blinking_slow: bool,
) -> ([u8; 3], [u8; 3]) {
let mut fg = color_to_rgb(&cell.fg, true);
let mut bg = color_to_rgb(&cell.bg, false);
for modifier in cell.modifier.iter() {
match modifier {
Modifier::DIM => {
fg = dim_rgb(fg);
bg = dim_rgb(bg);
}
Modifier::REVERSED => std::mem::swap(&mut fg, &mut bg),
Modifier::HIDDEN => fg = bg,
Modifier::SLOW_BLINK => {
always_redraw_list.insert((x, y));
if blinking_slow { fg = bg; }
}
Modifier::RAPID_BLINK => {
always_redraw_list.insert((x, y));
if blinking_fast { fg = bg; }
}
_ => {}
}
}
(fg, bg)
}
fn color_to_rgb(color: &Color, is_fg: bool) -> [u8; 3] {
match color {
Color::Reset if is_fg => [204, 204, 255],
Color::Reset => [5, 1, 121],
Color::Black => [0, 0, 0],
Color::Red => [139, 0, 0],
Color::Green => [0, 100, 0],
Color::Yellow => [255, 215, 0],
Color::Blue => [0, 0, 139],
Color::Magenta => [255, 0, 255],
Color::Cyan => [0, 0, 255],
Color::Gray => [128, 128, 128],
Color::DarkGray => [64, 64, 64],
Color::LightRed => [255, 0, 0],
Color::LightGreen => [0, 255, 0],
Color::LightBlue => [173, 216, 230],
Color::LightYellow => [255, 255, 224],
Color::LightMagenta => [139, 0, 139],
Color::LightCyan => [224, 255, 255],
Color::White => [255, 255, 255],
Color::Indexed(i) => [i.wrapping_mul(*i), i.wrapping_add(*i), *i],
Color::Rgb(r, g, b) => [*r, *g, *b],
}
}
fn dim_rgb(c: [u8; 3]) -> [u8; 3] {
const F: u32 = 77; // ~30% brightness
[
((c[0] as u32 * F + 127) / 255) as u8,
((c[1] as u32 * F + 127) / 255) as u8,
((c[2] as u32 * F + 127) / 255) as u8,
]
}

View File

@@ -1,7 +1,10 @@
mod block_renderer;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use block_renderer::BlockCharBackend;
use clap::Parser; use clap::Parser;
use doux::EngineMetrics; use doux::EngineMetrics;
use eframe::NativeOptions; use eframe::NativeOptions;
@@ -99,7 +102,7 @@ impl FontChoice {
]; ];
} }
type TerminalType = Terminal<RataguiBackend<EmbeddedGraphics>>; type TerminalType = Terminal<RataguiBackend<BlockCharBackend>>;
fn create_terminal(font: FontChoice) -> TerminalType { fn create_terminal(font: FontChoice) -> TerminalType {
let (regular, bold, italic) = match font { let (regular, bold, italic) = match font {
@@ -123,7 +126,22 @@ fn create_terminal(font: FontChoice) -> TerminalType {
FontChoice::Size10x20 => (mono_10x20_atlas(), None, None), FontChoice::Size10x20 => (mono_10x20_atlas(), None, None),
}; };
let soft = SoftBackend::<EmbeddedGraphics>::new(80, 24, regular, bold, italic); let eg = SoftBackend::<EmbeddedGraphics>::new(80, 24, regular, bold, italic);
let soft = SoftBackend {
buffer: eg.buffer,
cursor: eg.cursor,
cursor_pos: eg.cursor_pos,
char_width: eg.char_width,
char_height: eg.char_height,
blink_counter: eg.blink_counter,
blinking_fast: eg.blinking_fast,
blinking_slow: eg.blinking_slow,
rgb_pixmap: eg.rgb_pixmap,
always_redraw_list: eg.always_redraw_list,
raster_backend: BlockCharBackend {
inner: eg.raster_backend,
},
};
Terminal::new(RataguiBackend::new("cagire", soft)).expect("terminal") Terminal::new(RataguiBackend::new("cagire", soft)).expect("terminal")
} }
@@ -143,6 +161,10 @@ struct CagireDesktop {
_analysis_handle: Option<AnalysisHandle>, _analysis_handle: Option<AnalysisHandle>,
midi_rx: Receiver<MidiCommand>, midi_rx: Receiver<MidiCommand>,
current_font: FontChoice, current_font: FontChoice,
zoom_factor: f32,
fullscreen: bool,
decorations: bool,
always_on_top: bool,
mouse_x: Arc<AtomicU32>, mouse_x: Arc<AtomicU32>,
mouse_y: Arc<AtomicU32>, mouse_y: Arc<AtomicU32>,
mouse_down: Arc<AtomicU32>, mouse_down: Arc<AtomicU32>,
@@ -161,8 +183,10 @@ impl CagireDesktop {
let current_font = FontChoice::from_setting(&b.settings.display.font); let current_font = FontChoice::from_setting(&b.settings.display.font);
let terminal = create_terminal(current_font); let terminal = create_terminal(current_font);
let zoom_factor = b.settings.display.zoom_factor;
cc.egui_ctx.set_visuals(egui::Visuals::dark()); cc.egui_ctx.set_visuals(egui::Visuals::dark());
cc.egui_ctx.set_zoom_factor(zoom_factor);
Self { Self {
app: b.app, app: b.app,
@@ -180,6 +204,10 @@ impl CagireDesktop {
_analysis_handle: b.analysis_handle, _analysis_handle: b.analysis_handle,
midi_rx: b.midi_rx, midi_rx: b.midi_rx,
current_font, current_font,
zoom_factor,
fullscreen: false,
decorations: true,
always_on_top: false,
mouse_x: b.mouse_x, mouse_x: b.mouse_x,
mouse_y: b.mouse_y, mouse_y: b.mouse_y,
mouse_down: b.mouse_down, mouse_down: b.mouse_down,
@@ -412,7 +440,15 @@ impl eframe::App for CagireDesktop {
} }
let current_font = self.current_font; let current_font = self.current_font;
let current_zoom = self.zoom_factor;
let current_fullscreen = self.fullscreen;
let current_decorations = self.decorations;
let current_always_on_top = self.always_on_top;
let mut new_font = None; let mut new_font = None;
let mut new_zoom = None;
let mut toggle_fullscreen = false;
let mut toggle_decorations = false;
let mut toggle_always_on_top = false;
egui::CentralPanel::default() egui::CentralPanel::default()
.frame(egui::Frame::NONE.fill(egui::Color32::BLACK)) .frame(egui::Frame::NONE.fill(egui::Color32::BLACK))
@@ -449,6 +485,38 @@ impl eframe::App for CagireDesktop {
} }
} }
}); });
ui.menu_button("Zoom", |ui| {
for &level in &[0.5_f32, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] {
let selected = (current_zoom - level).abs() < 0.01;
let label = format!("{:.0}%", level * 100.0);
if ui.selectable_label(selected, label).clicked() {
new_zoom = Some(level);
ui.close();
}
}
});
ui.separator();
if ui
.selectable_label(current_fullscreen, "Fullscreen")
.clicked()
{
toggle_fullscreen = true;
ui.close();
}
if ui
.selectable_label(current_always_on_top, "Always On Top")
.clicked()
{
toggle_always_on_top = true;
ui.close();
}
if ui
.selectable_label(!current_decorations, "Borderless")
.clicked()
{
toggle_decorations = true;
ui.close();
}
}); });
}); });
@@ -459,6 +527,30 @@ impl eframe::App for CagireDesktop {
settings.display.font = font.to_setting().to_string(); settings.display.font = font.to_setting().to_string();
settings.save(); settings.save();
} }
if let Some(zoom) = new_zoom {
self.zoom_factor = zoom;
ctx.set_zoom_factor(zoom);
let mut settings = Settings::load();
settings.display.zoom_factor = zoom;
settings.save();
}
if toggle_fullscreen {
self.fullscreen = !self.fullscreen;
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen));
}
if toggle_always_on_top {
self.always_on_top = !self.always_on_top;
let level = if self.always_on_top {
egui::WindowLevel::AlwaysOnTop
} else {
egui::WindowLevel::Normal
};
ctx.send_viewport_cmd(egui::ViewportCommand::WindowLevel(level));
}
if toggle_decorations {
self.decorations = !self.decorations;
ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(self.decorations));
}
ctx.request_repaint_after(Duration::from_millis( ctx.request_repaint_after(Duration::from_millis(
self.app.audio.config.refresh_rate.millis(), self.app.audio.config.refresh_rate.millis(),
@@ -473,7 +565,7 @@ impl eframe::App for CagireDesktop {
} }
fn load_icon() -> egui::IconData { fn load_icon() -> egui::IconData {
const ICON_BYTES: &[u8] = include_bytes!("../../assets/Cagire.png"); const ICON_BYTES: &[u8] = include_bytes!("../../../assets/Cagire.png");
let img = image::load_from_memory(ICON_BYTES) let img = image::load_from_memory(ICON_BYTES)
.expect("Failed to load embedded icon") .expect("Failed to load embedded icon")

View File

@@ -267,4 +267,9 @@ pub enum AppCommand {
SavePrelude, SavePrelude,
EvaluatePrelude, EvaluatePrelude,
ClosePreludeEditor, ClosePreludeEditor,
// Onboarding
DismissOnboarding,
ResetOnboarding,
GoToHelpTopic(usize),
} }

View File

@@ -1180,10 +1180,12 @@ fn sequencer_loop(
// Route commands: audio direct to doux, MIDI through dispatcher // Route commands: audio direct to doux, MIDI through dispatcher
for tsc in output.audio_commands { for tsc in output.audio_commands {
if let Some((midi_cmd, dur)) = parse_midi_command(&tsc.cmd) { if let Some((midi_cmd, dur, delta_secs)) = parse_midi_command(&tsc.cmd) {
let target_time_us =
current_time_us + (delta_secs * 1_000_000.0) as SyncTime;
let _ = dispatch_tx.send(TimedMidiCommand { let _ = dispatch_tx.send(TimedMidiCommand {
command: MidiDispatch::Send(midi_cmd.clone()), command: MidiDispatch::Send(midi_cmd.clone()),
target_time_us: current_time_us, target_time_us,
}); });
if let ( if let (
@@ -1196,7 +1198,7 @@ fn sequencer_loop(
Some(dur_secs), Some(dur_secs),
) = (&midi_cmd, dur) ) = (&midi_cmd, dur)
{ {
let off_time_us = current_time_us + (dur_secs * 1_000_000.0) as SyncTime; let off_time_us = target_time_us + (dur_secs * 1_000_000.0) as SyncTime;
let _ = dispatch_tx.send(TimedMidiCommand { let _ = dispatch_tx.send(TimedMidiCommand {
command: MidiDispatch::Send(MidiCommand::NoteOff { command: MidiDispatch::Send(MidiCommand::NoteOff {
device: *device, device: *device,
@@ -1229,7 +1231,7 @@ fn sequencer_loop(
} }
} }
fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> { fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>, f64)> {
if !cmd.starts_with("/midi/") { if !cmd.starts_with("/midi/") {
return None; return None;
} }
@@ -1254,10 +1256,10 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
}; };
let device: u8 = find_param("dev").and_then(|s| s.parse().ok()).unwrap_or(0); let device: u8 = find_param("dev").and_then(|s| s.parse().ok()).unwrap_or(0);
let delta: f64 = find_param("delta").and_then(|s| s.parse().ok()).unwrap_or(0.0);
match parts[1] { match parts[1] {
"note" => { "note" => {
// /midi/note/<note>/vel/<vel>/chan/<chan>/dur/<dur>/dev/<dev>
let note: u8 = parts.get(2)?.parse().ok()?; let note: u8 = parts.get(2)?.parse().ok()?;
let vel: u8 = find_param("vel")?.parse().ok()?; let vel: u8 = find_param("vel")?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?;
@@ -1270,10 +1272,10 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
velocity: vel, velocity: vel,
}, },
dur, dur,
delta,
)) ))
} }
"cc" => { "cc" => {
// /midi/cc/<cc>/<val>/chan/<chan>/dev/<dev>
let cc: u8 = parts.get(2)?.parse().ok()?; let cc: u8 = parts.get(2)?.parse().ok()?;
let val: u8 = parts.get(3)?.parse().ok()?; let val: u8 = parts.get(3)?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?;
@@ -1285,10 +1287,10 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
value: val, value: val,
}, },
None, None,
delta,
)) ))
} }
"bend" => { "bend" => {
// /midi/bend/<value>/chan/<chan>/dev/<dev>
let value: u16 = parts.get(2)?.parse().ok()?; let value: u16 = parts.get(2)?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?;
Some(( Some((
@@ -1298,10 +1300,10 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
value, value,
}, },
None, None,
delta,
)) ))
} }
"pressure" => { "pressure" => {
// /midi/pressure/<value>/chan/<chan>/dev/<dev>
let value: u8 = parts.get(2)?.parse().ok()?; let value: u8 = parts.get(2)?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?;
Some(( Some((
@@ -1311,10 +1313,10 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
value, value,
}, },
None, None,
delta,
)) ))
} }
"program" => { "program" => {
// /midi/program/<value>/chan/<chan>/dev/<dev>
let program: u8 = parts.get(2)?.parse().ok()?; let program: u8 = parts.get(2)?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?; let chan: u8 = find_param("chan")?.parse().ok()?;
Some(( Some((
@@ -1324,12 +1326,13 @@ fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
program, program,
}, },
None, None,
delta,
)) ))
} }
"clock" => Some((MidiCommand::Clock { device }, None)), "clock" => Some((MidiCommand::Clock { device }, None, delta)),
"start" => Some((MidiCommand::Start { device }, None)), "start" => Some((MidiCommand::Start { device }, None, delta)),
"stop" => Some((MidiCommand::Stop { device }, None)), "stop" => Some((MidiCommand::Stop { device }, None, delta)),
"continue" => Some((MidiCommand::Continue { device }, None)), "continue" => Some((MidiCommand::Continue { device }, None, delta)),
_ => None, _ => None,
} }
} }

View File

@@ -86,6 +86,7 @@ pub fn init(args: InitArgs) -> Init {
app.ui.color_scheme = settings.display.color_scheme; app.ui.color_scheme = settings.display.color_scheme;
app.ui.hue_rotation = settings.display.hue_rotation; app.ui.hue_rotation = settings.display.hue_rotation;
app.audio.config.layout = settings.display.layout; app.audio.config.layout = settings.display.layout;
app.ui.onboarding_dismissed = settings.display.onboarding_dismissed.clone();
let base_theme = settings.display.color_scheme.to_theme(); let base_theme = settings.display.color_scheme.to_theme();
let rotated = let rotated =

View File

@@ -158,6 +158,8 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
time: None, time: None,
}); });
} }
KeyCode::Char('s') => super::open_save(ctx),
KeyCode::Char('l') => super::open_load(ctx),
KeyCode::Char('?') => { KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
} }

View File

@@ -3,7 +3,7 @@ use std::sync::atomic::Ordering;
use super::{InputContext, InputResult}; use super::{InputContext, InputResult};
use crate::commands::AppCommand; use crate::commands::AppCommand;
use crate::state::{ConfirmAction, DictFocus, HelpFocus, Modal}; use crate::state::{ConfirmAction, DictFocus, FlashKind, HelpFocus, Modal};
pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
@@ -26,7 +26,22 @@ pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputRe
KeyCode::Esc if !ctx.app.ui.help_search_query.is_empty() => { KeyCode::Esc if !ctx.app.ui.help_search_query.is_empty() => {
ctx.dispatch(AppCommand::HelpClearSearch); ctx.dispatch(AppCommand::HelpClearSearch);
} }
KeyCode::Esc if ctx.app.ui.help_focused_block.is_some() => {
ctx.app.ui.help_focused_block = None;
}
KeyCode::Tab => ctx.dispatch(AppCommand::HelpToggleFocus), KeyCode::Tab => ctx.dispatch(AppCommand::HelpToggleFocus),
KeyCode::Char('n') if ctx.app.ui.help_focus == HelpFocus::Content => {
navigate_code_block(ctx, true);
}
KeyCode::Char('p') if ctx.app.ui.help_focus == HelpFocus::Content => {
navigate_code_block(ctx, false);
}
KeyCode::Enter
if ctx.app.ui.help_focus == HelpFocus::Content
&& ctx.app.ui.help_focused_block.is_some() =>
{
execute_focused_block(ctx);
}
KeyCode::Char('j') | KeyCode::Down if ctrl => { KeyCode::Char('j') | KeyCode::Down if ctrl => {
ctx.dispatch(AppCommand::HelpNextTopic(5)); ctx.dispatch(AppCommand::HelpNextTopic(5));
} }
@@ -49,6 +64,8 @@ pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputRe
selected: false, selected: false,
})); }));
} }
KeyCode::Char('s') => super::open_save(ctx),
KeyCode::Char('l') => super::open_load(ctx),
KeyCode::Char('?') => { KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
} }
@@ -62,6 +79,57 @@ pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputRe
InputResult::Continue InputResult::Continue
} }
fn navigate_code_block(ctx: &mut InputContext, forward: bool) {
let cache = ctx.app.ui.help_parsed.borrow();
let Some(parsed) = cache[ctx.app.ui.help_topic].as_ref() else {
return;
};
let count = parsed.code_blocks.len();
if count == 0 {
return;
}
let next = match ctx.app.ui.help_focused_block {
Some(cur) if forward => (cur + 1) % count,
Some(0) if !forward => count - 1,
Some(cur) if !forward => cur - 1,
_ if forward => 0,
_ => count - 1,
};
let scroll_to = parsed.code_blocks[next].start_line.saturating_sub(2);
drop(cache);
ctx.app.ui.help_focused_block = Some(next);
*ctx.app.ui.help_scroll_mut() = scroll_to;
}
fn execute_focused_block(ctx: &mut InputContext) {
let source = {
let cache = ctx.app.ui.help_parsed.borrow();
let Some(parsed) = cache[ctx.app.ui.help_topic].as_ref() else {
return;
};
let idx = ctx.app.ui.help_focused_block.unwrap();
let Some(block) = parsed.code_blocks.get(idx) else {
return;
};
block.source.clone()
};
let cleaned: String = source
.lines()
.map(|l| l.split(" => ").next().unwrap_or(l))
.collect::<Vec<_>>()
.join("\n");
match ctx
.app
.execute_script_oneshot(&cleaned, ctx.link, ctx.audio_tx)
{
Ok(()) => ctx.app.ui.flash("Executed", 100, FlashKind::Info),
Err(e) => ctx
.app
.ui
.flash(&format!("Error: {e}"), 200, FlashKind::Error),
}
}
pub(super) fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { pub(super) fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
@@ -100,6 +168,8 @@ pub(super) fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputRe
selected: false, selected: false,
})); }));
} }
KeyCode::Char('s') => super::open_save(ctx),
KeyCode::Char('l') => super::open_load(ctx),
KeyCode::Char('?') => { KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
} }

View File

@@ -84,18 +84,7 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
ctx.dispatch(AppCommand::OpenModal(Modal::Editor)); ctx.dispatch(AppCommand::OpenModal(Modal::Editor));
} }
KeyCode::Char('t') => ctx.dispatch(AppCommand::ToggleSteps), KeyCode::Char('t') => ctx.dispatch(AppCommand::ToggleSteps),
KeyCode::Char('s') => { KeyCode::Char('s') => super::open_save(ctx),
use crate::state::file_browser::FileBrowserState;
let initial = ctx
.app
.project_state
.file_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
let state = FileBrowserState::new_save(initial);
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(Box::new(state))));
}
KeyCode::Char('z') if ctrl && !shift => { KeyCode::Char('z') if ctrl && !shift => {
ctx.dispatch(AppCommand::Undo); ctx.dispatch(AppCommand::Undo);
} }
@@ -115,25 +104,7 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
ctx.dispatch(AppCommand::DuplicateSteps); ctx.dispatch(AppCommand::DuplicateSteps);
} }
KeyCode::Char('h') if ctrl => ctx.dispatch(AppCommand::HardenSteps), KeyCode::Char('h') if ctrl => ctx.dispatch(AppCommand::HardenSteps),
KeyCode::Char('l') => { KeyCode::Char('l') => super::open_load(ctx),
use crate::state::file_browser::FileBrowserState;
let default_dir = ctx
.app
.project_state
.file_path
.as_ref()
.and_then(|p| p.parent())
.map(|p| {
let mut s = p.display().to_string();
if !s.ends_with('/') {
s.push('/');
}
s
})
.unwrap_or_default();
let state = FileBrowserState::new_load(default_dir);
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(Box::new(state))));
}
KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp), KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp),
KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown), KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown),
KeyCode::Char('T') => { KeyCode::Char('T') => {

View File

@@ -147,6 +147,39 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
} }
} }
fn open_save(ctx: &mut InputContext) {
use crate::state::file_browser::FileBrowserState;
let initial = ctx
.app
.project_state
.file_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
let state = FileBrowserState::new_save(initial);
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(Box::new(state))));
}
fn open_load(ctx: &mut InputContext) {
use crate::state::file_browser::FileBrowserState;
let default_dir = ctx
.app
.project_state
.file_path
.as_ref()
.and_then(|p| p.parent())
.map(|p| {
let mut s = p.display().to_string();
if !s.ends_with('/') {
s.push('/');
}
s
})
.unwrap_or_default();
let state = FileBrowserState::new_load(default_dir);
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(Box::new(state))));
}
fn load_project_samples(ctx: &mut InputContext) { fn load_project_samples(ctx: &mut InputContext) {
let paths = ctx.app.project_state.project.sample_paths.clone(); let paths = ctx.app.project_state.project.sample_paths.clone();
if paths.is_empty() { if paths.is_empty() {

View File

@@ -525,6 +525,37 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
_ => {} _ => {}
} }
} }
Modal::Onboarding { .. } => {
let pages = ctx.app.page.onboarding();
let page_count = pages.len();
match key.code {
KeyCode::Right | KeyCode::Char('l') if page_count > 1 => {
if let Modal::Onboarding { page } = &mut ctx.app.ui.modal {
if *page + 1 < page_count {
*page += 1;
}
}
}
KeyCode::Left | KeyCode::Char('h') if page_count > 1 => {
if let Modal::Onboarding { page } = &mut ctx.app.ui.modal {
*page = page.saturating_sub(1);
}
}
KeyCode::Char('?') | KeyCode::F(1) => {
if let Some(topic) = ctx.app.page.help_topic_index() {
ctx.dispatch(AppCommand::GoToHelpTopic(topic));
} else {
ctx.dispatch(AppCommand::CloseModal);
}
}
KeyCode::Enter => {
ctx.dispatch(AppCommand::DismissOnboarding);
ctx.dispatch(AppCommand::CloseModal);
ctx.app.save_settings(ctx.link);
}
_ => ctx.dispatch(AppCommand::CloseModal),
}
}
Modal::None => unreachable!(), Modal::None => unreachable!(),
} }
InputResult::Continue InputResult::Continue

View File

@@ -87,6 +87,9 @@ pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) {
} }
} }
} }
OptionsFocus::ResetOnboarding => {
ctx.dispatch(AppCommand::ResetOnboarding);
}
OptionsFocus::MidiInput0 OptionsFocus::MidiInput0
| OptionsFocus::MidiInput1 | OptionsFocus::MidiInput1
| OptionsFocus::MidiInput2 | OptionsFocus::MidiInput2
@@ -162,6 +165,8 @@ pub(super) fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> Inpu
ctx.playing ctx.playing
.store(ctx.app.playback.playing, Ordering::Relaxed); .store(ctx.app.playback.playing, Ordering::Relaxed);
} }
KeyCode::Char('s') => super::open_save(ctx),
KeyCode::Char('l') => super::open_load(ctx),
KeyCode::Char('?') => { KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
} }

View File

@@ -249,6 +249,8 @@ pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> Inp
ctx.dispatch(AppCommand::ClearSolos); ctx.dispatch(AppCommand::ClearSolos);
ctx.app.send_mute_state(ctx.seq_cmd_tx); ctx.app.send_mute_state(ctx.seq_cmd_tx);
} }
KeyCode::Char('s') => super::open_save(ctx),
KeyCode::Char('l') => super::open_load(ctx),
KeyCode::Char('?') => { KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
} }

View File

@@ -27,6 +27,7 @@ pub const DOCS: &[DocEntry] = &[
Topic("The Dictionary", include_str!("../../docs/dictionary.md")), Topic("The Dictionary", include_str!("../../docs/dictionary.md")),
Topic("The Stack", include_str!("../../docs/stack.md")), Topic("The Stack", include_str!("../../docs/stack.md")),
Topic("Creating Words", include_str!("../../docs/definitions.md")), Topic("Creating Words", include_str!("../../docs/definitions.md")),
Topic("Control Flow", include_str!("../../docs/control_flow.md")),
Topic("The Prelude", include_str!("../../docs/prelude.md")), Topic("The Prelude", include_str!("../../docs/prelude.md")),
Topic("Oddities", include_str!("../../docs/oddities.md")), Topic("Oddities", include_str!("../../docs/oddities.md")),
// Audio Engine // Audio Engine
@@ -67,6 +68,8 @@ pub const DOCS: &[DocEntry] = &[
"Generators", "Generators",
include_str!("../../docs/tutorial_generators.md"), include_str!("../../docs/tutorial_generators.md"),
), ),
Topic("Timing with at", include_str!("../../docs/tutorial_at.md")),
Topic("Using Variables", include_str!("../../docs/tutorial_variables.md")),
]; ];
pub fn topic_count() -> usize { pub fn topic_count() -> usize {

View File

@@ -91,4 +91,110 @@ impl Page {
*self = page; *self = page;
} }
} }
pub const fn onboarding(self) -> &'static [(&'static str, &'static [(&'static str, &'static str)])] {
match self {
Page::Main => &[
(
"The step sequencer grid. Each cell is a Forth script that produces sound when evaluated. During playback, active steps run left-to-right, top-to-bottom. Toggle steps on/off with t to build your pattern. The left panel shows playing patterns, the right side shows VU meters.",
&[
("Arrows", "navigate grid"),
("Space", "play / stop"),
("Enter", "edit step script"),
("t", "toggle step on/off"),
("p", "preview script"),
("Tab", "sample browser"),
("?", "all keybindings"),
],
),
(
"Enter opens the script editor (Esc saves and closes). Select ranges with Shift+arrows for bulk operations. Linked steps share one script: edit the source and all links update. Adjust pattern length/speed directly, or use euclidean distribution to spread a step rhythmically.",
&[
("Shift+Arrows", "select range"),
("Ctrl+C / V", "copy / paste steps"),
("Ctrl+D", "duplicate steps"),
("Ctrl+B", "paste as linked copies"),
("< > / [ ]", "length / speed"),
("e", "euclidean distribution"),
("+ - / T", "tempo adjust / set"),
],
),
],
Page::Patterns => &[
(
"Organize your project into banks and patterns. The left column lists 32 banks, the right shows patterns in the selected bank. Stage patterns to play or stop, then commit to apply all changes at once.",
&[
("Arrows", "navigate"),
("Enter", "open in sequencer"),
("Space", "stage play/stop"),
("c", "commit changes"),
("r", "rename"),
("e", "properties"),
("?", "all keys"),
],
),
(
"Mute and solo patterns to control the mix. Use euclidean distribution to generate rhythmic patterns from a single step. Select multiple patterns with Shift for bulk operations.",
&[
("m", "stage mute"),
("s", "stage solo"),
("E", "euclidean"),
("Shift+↑↓", "select range"),
("y", "copy"),
("P", "paste"),
],
),
],
Page::Engine => &[(
"Audio engine configuration. Select output and input devices, adjust buffer size and polyphony, and manage sample directories. The right side shows a live scope and spectrum analyzer.",
&[
("Tab", "switch section"),
("↑↓", "navigate"),
("←→", "adjust"),
("R", "restart engine"),
("A", "add samples"),
("?", "all keys"),
],
)],
Page::Options => &[(
"Global settings for display, UI, Link sync, MIDI, etc. All changes save automatically. Tutorial can be reset from here!",
&[
("↑↓", "navigate"),
("←→", "change value"),
("?", "all keys"),
],
)],
Page::Help => &[(
"Interactive documentation with executable Forth examples. Browse topics on the left, read content on the right. Code blocks can be run directly and evaluated by the sequencer engine.",
&[
("Tab", "switch panels"),
("↑↓", "navigate"),
("Enter", "run code block"),
("n/p", "next/prev example"),
("/", "search"),
("?", "all keys"),
],
)],
Page::Dict => &[(
"Complete reference of all Forth words by category. Each entry shows the word name, stack effect signature, description, and a usage example. Search filters across all categories.",
&[
("Tab", "switch panels"),
("↑↓", "navigate"),
("/", "search"),
("?", "all keys"),
],
)],
}
}
pub const fn help_topic_index(self) -> Option<usize> {
match self {
Page::Main => Some(5), // "Using the Sequencer"
Page::Patterns => Some(3), // "Banks & Patterns"
Page::Engine => Some(14), // "Introduction" (Audio Engine)
Page::Help => Some(0), // "Welcome"
Page::Dict => Some(7), // "About Forth"
Page::Options => None,
}
}
} }

View File

@@ -17,11 +17,13 @@ pub fn select_topic(ui: &mut UiState, index: usize) {
pub fn next_topic(ui: &mut UiState, n: usize) { pub fn next_topic(ui: &mut UiState, n: usize) {
let count = docs::topic_count(); let count = docs::topic_count();
ui.help_topic = (ui.help_topic + n) % count; ui.help_topic = (ui.help_topic + n) % count;
ui.help_focused_block = None;
} }
pub fn prev_topic(ui: &mut UiState, n: usize) { pub fn prev_topic(ui: &mut UiState, n: usize) {
let count = docs::topic_count(); let count = docs::topic_count();
ui.help_topic = (ui.help_topic + count - (n % count)) % count; ui.help_topic = (ui.help_topic + count - (n % count)) % count;
ui.help_focused_block = None;
} }
pub fn scroll_down(ui: &mut UiState, n: usize) { pub fn scroll_down(ui: &mut UiState, n: usize) {

View File

@@ -45,18 +45,26 @@ pub struct DisplaySettings {
pub show_completion: bool, pub show_completion: bool,
#[serde(default = "default_font")] #[serde(default = "default_font")]
pub font: String, pub font: String,
#[serde(default = "default_zoom")]
pub zoom_factor: f32,
#[serde(default)] #[serde(default)]
pub color_scheme: ColorScheme, pub color_scheme: ColorScheme,
#[serde(default)] #[serde(default)]
pub layout: MainLayout, pub layout: MainLayout,
#[serde(default)] #[serde(default)]
pub hue_rotation: f32, pub hue_rotation: f32,
#[serde(default)]
pub onboarding_dismissed: Vec<String>,
} }
fn default_font() -> String { fn default_font() -> String {
"8x13".to_string() "8x13".to_string()
} }
fn default_zoom() -> f32 {
1.5
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct LinkSettings { pub struct LinkSettings {
pub enabled: bool, pub enabled: bool,
@@ -88,9 +96,11 @@ impl Default for DisplaySettings {
show_preview: true, show_preview: true,
show_completion: true, show_completion: true,
font: default_font(), font: default_font(),
zoom_factor: default_zoom(),
color_scheme: ColorScheme::default(), color_scheme: ColorScheme::default(),
layout: MainLayout::default(), layout: MainLayout::default(),
hue_rotation: 0.0, hue_rotation: 0.0,
onboarding_dismissed: Vec::new(),
} }
} }
} }

View File

@@ -90,4 +90,5 @@ pub enum Modal {
steps: String, steps: String,
rotation: String, rotation: String,
}, },
Onboarding { page: usize },
} }

View File

@@ -22,6 +22,7 @@ pub enum OptionsFocus {
MidiInput1, MidiInput1,
MidiInput2, MidiInput2,
MidiInput3, MidiInput3,
ResetOnboarding,
} }
impl CyclicEnum for OptionsFocus { impl CyclicEnum for OptionsFocus {
@@ -45,6 +46,7 @@ impl CyclicEnum for OptionsFocus {
Self::MidiInput1, Self::MidiInput1,
Self::MidiInput2, Self::MidiInput2,
Self::MidiInput3, Self::MidiInput3,
Self::ResetOnboarding,
]; ];
} }
@@ -68,6 +70,7 @@ const FOCUS_LINES: &[(OptionsFocus, usize)] = &[
(OptionsFocus::MidiInput1, 33), (OptionsFocus::MidiInput1, 33),
(OptionsFocus::MidiInput2, 34), (OptionsFocus::MidiInput2, 34),
(OptionsFocus::MidiInput3, 35), (OptionsFocus::MidiInput3, 35),
(OptionsFocus::ResetOnboarding, 39),
]; ];
impl OptionsFocus { impl OptionsFocus {

View File

@@ -1,6 +1,7 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use cagire_markdown::ParsedMarkdown;
use cagire_ratatui::Sparkles; use cagire_ratatui::Sparkles;
use tachyonfx::{fx, Effect, EffectManager, Interpolation}; use tachyonfx::{fx, Effect, EffectManager, Interpolation};
@@ -49,6 +50,8 @@ pub struct UiState {
pub help_scrolls: Vec<usize>, pub help_scrolls: Vec<usize>,
pub help_search_active: bool, pub help_search_active: bool,
pub help_search_query: String, pub help_search_query: String,
pub help_focused_block: Option<usize>,
pub help_parsed: RefCell<Vec<Option<ParsedMarkdown>>>,
pub dict_focus: DictFocus, pub dict_focus: DictFocus,
pub dict_category: usize, pub dict_category: usize,
pub dict_scrolls: Vec<usize>, pub dict_scrolls: Vec<usize>,
@@ -66,6 +69,7 @@ pub struct UiState {
pub prev_modal_open: bool, pub prev_modal_open: bool,
pub prev_page: Page, pub prev_page: Page,
pub prev_show_title: bool, pub prev_show_title: bool,
pub onboarding_dismissed: Vec<String>,
} }
impl Default for UiState { impl Default for UiState {
@@ -81,6 +85,8 @@ impl Default for UiState {
help_scrolls: vec![0; crate::model::docs::topic_count()], help_scrolls: vec![0; crate::model::docs::topic_count()],
help_search_active: false, help_search_active: false,
help_search_query: String::new(), help_search_query: String::new(),
help_focused_block: None,
help_parsed: RefCell::new((0..crate::model::docs::topic_count()).map(|_| None).collect()),
dict_focus: DictFocus::default(), dict_focus: DictFocus::default(),
dict_category: 0, dict_category: 0,
dict_scrolls: vec![0; crate::model::categories::category_count()], dict_scrolls: vec![0; crate::model::categories::category_count()],
@@ -98,6 +104,7 @@ impl Default for UiState {
prev_modal_open: false, prev_modal_open: false,
prev_page: Page::default(), prev_page: Page::default(),
prev_show_title: true, prev_show_title: true,
onboarding_dismissed: Vec::new(),
} }
} }
} }

View File

@@ -4,7 +4,6 @@ use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line as RLine, Span}; use ratatui::text::{Line as RLine, Span};
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap}; use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap};
use ratatui::Frame; use ratatui::Frame;
#[cfg(not(feature = "desktop"))]
use tui_big_text::{BigText, PixelSize}; use tui_big_text::{BigText, PixelSize};
use crate::app::App; use crate::app::App;
@@ -129,10 +128,7 @@ fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
} }
const WELCOME_TOPIC: usize = 0; const WELCOME_TOPIC: usize = 0;
#[cfg(not(feature = "desktop"))]
const BIG_TITLE_HEIGHT: u16 = 6; const BIG_TITLE_HEIGHT: u16 = 6;
#[cfg(feature = "desktop")]
const BIG_TITLE_HEIGHT: u16 = 3;
fn render_content(frame: &mut Frame, app: &App, area: Rect) { fn render_content(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get(); let theme = theme::get();
@@ -146,19 +142,12 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
Layout::vertical([Constraint::Length(BIG_TITLE_HEIGHT), Constraint::Fill(1)]) Layout::vertical([Constraint::Length(BIG_TITLE_HEIGHT), Constraint::Fill(1)])
.areas(area); .areas(area);
#[cfg(not(feature = "desktop"))]
let big_title = BigText::builder() let big_title = BigText::builder()
.pixel_size(PixelSize::Quadrant) .pixel_size(PixelSize::Quadrant)
.style(Style::new().fg(theme.markdown.h1).bold()) .style(Style::new().fg(theme.markdown.h1).bold())
.lines(vec!["CAGIRE".into()]) .lines(vec!["CAGIRE".into()])
.centered() .centered()
.build(); .build();
#[cfg(feature = "desktop")]
let big_title = Paragraph::new(RLine::from(Span::styled(
"CAGIRE",
Style::new().fg(theme.markdown.h1).bold(),
)))
.alignment(ratatui::layout::Alignment::Center);
let subtitle = Paragraph::new(RLine::from(Span::styled( let subtitle = Paragraph::new(RLine::from(Span::styled(
"A Forth Sequencer", "A Forth Sequencer",
@@ -166,12 +155,8 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
))) )))
.alignment(ratatui::layout::Alignment::Center); .alignment(ratatui::layout::Alignment::Center);
#[cfg(not(feature = "desktop"))]
let [big_area, subtitle_area] = let [big_area, subtitle_area] =
Layout::vertical([Constraint::Length(4), Constraint::Length(2)]).areas(title_area); Layout::vertical([Constraint::Length(4), Constraint::Length(2)]).areas(title_area);
#[cfg(feature = "desktop")]
let [big_area, subtitle_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(2)]).areas(title_area);
frame.render_widget(big_title, big_area); frame.render_widget(big_title, big_area);
frame.render_widget(subtitle, subtitle_area); frame.render_widget(subtitle, subtitle_area);
@@ -183,7 +168,15 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
let has_query = !query.is_empty(); let has_query = !query.is_empty();
let query_lower = query.to_lowercase(); let query_lower = query.to_lowercase();
let lines = cagire_markdown::parse(md, &AppTheme, &ForthHighlighter); // Populate parse cache for this topic
{
let mut cache = app.ui.help_parsed.borrow_mut();
if cache[app.ui.help_topic].is_none() {
cache[app.ui.help_topic] = Some(cagire_markdown::parse(md, &AppTheme, &ForthHighlighter));
}
}
let cache = app.ui.help_parsed.borrow();
let parsed = cache[app.ui.help_topic].as_ref().unwrap();
let has_search_bar = app.ui.help_search_active || has_query; let has_search_bar = app.ui.help_search_active || has_query;
let content_area = if has_search_bar { let content_area = if has_search_bar {
@@ -201,13 +194,33 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
let visible_height = content_area.height.saturating_sub(6) as usize; let visible_height = content_area.height.saturating_sub(6) as usize;
// Calculate total wrapped line count for accurate max_scroll // Calculate total wrapped line count for accurate max_scroll
let total_wrapped: usize = lines let total_wrapped: usize = parsed
.lines
.iter() .iter()
.map(|l| wrapped_line_count(l, content_width)) .map(|l| wrapped_line_count(l, content_width))
.sum(); .sum();
let max_scroll = total_wrapped.saturating_sub(visible_height); let max_scroll = total_wrapped.saturating_sub(visible_height);
let scroll = app.ui.help_scroll().min(max_scroll); let scroll = app.ui.help_scroll().min(max_scroll);
let mut lines = parsed.lines.clone();
// Highlight focused code block with background tint
if let Some(block_idx) = app.ui.help_focused_block {
if let Some(block) = parsed.code_blocks.get(block_idx) {
let tint_bg = theme.ui.surface;
for line in lines.iter_mut().take(block.end_line).skip(block.start_line) {
for (i, span) in line.spans.iter_mut().enumerate() {
let style = if i < 2 {
span.style.fg(theme.ui.accent).bg(tint_bg)
} else {
span.style.bg(tint_bg)
};
*span = Span::styled(span.content.clone(), style);
}
}
}
}
let lines: Vec<RLine> = if has_query { let lines: Vec<RLine> = if has_query {
lines lines
.into_iter() .into_iter()

View File

@@ -191,7 +191,7 @@ fn classify_word(word: &str, user_words: &HashSet<String>) -> (TokenKind, bool)
return (TokenKind::Note, false); return (TokenKind::Note, false);
} }
if word.len() > 1 && (word.starts_with('@') || word.starts_with('!')) { if word.len() > 1 && (word.starts_with('@') || word.starts_with('!') || word.starts_with(',')) {
return (TokenKind::Variable, false); return (TokenKind::Variable, false);
} }

View File

@@ -5,6 +5,8 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str
("F1F6", "Go to view", "Dict/Patterns/Options/Help/Sequencer/Engine"), ("F1F6", "Go to view", "Dict/Patterns/Options/Help/Sequencer/Engine"),
("Ctrl+←→↑↓", "Navigate", "Switch between adjacent views"), ("Ctrl+←→↑↓", "Navigate", "Switch between adjacent views"),
("q", "Quit", "Quit application"), ("q", "Quit", "Quit application"),
("s", "Save", "Save project"),
("l", "Load", "Load project"),
("?", "Keybindings", "Show this help"), ("?", "Keybindings", "Show this help"),
]; ];
@@ -31,8 +33,6 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str
bindings.push(("T", "Set tempo", "Open tempo input")); bindings.push(("T", "Set tempo", "Open tempo input"));
bindings.push(("L", "Set length", "Open length input")); bindings.push(("L", "Set length", "Open length input"));
bindings.push(("S", "Set speed", "Open speed input")); bindings.push(("S", "Set speed", "Open speed input"));
bindings.push(("s", "Save", "Save project"));
bindings.push(("l", "Load", "Load project"));
bindings.push(("f", "Fill", "Toggle fill mode (hold)")); bindings.push(("f", "Fill", "Toggle fill mode (hold)"));
bindings.push(("r", "Rename", "Rename current step")); bindings.push(("r", "Rename", "Rename current step"));
bindings.push(("Ctrl+R", "Run", "Run step script immediately")); bindings.push(("Ctrl+R", "Run", "Run step script immediately"));
@@ -88,8 +88,11 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str
bindings.push(("Tab", "Topic", "Next topic")); bindings.push(("Tab", "Topic", "Next topic"));
bindings.push(("Shift+Tab", "Topic", "Previous topic")); bindings.push(("Shift+Tab", "Topic", "Previous topic"));
bindings.push(("PgUp/Dn", "Page", "Page scroll")); bindings.push(("PgUp/Dn", "Page", "Page scroll"));
bindings.push(("n", "Next code", "Jump to next code block"));
bindings.push(("p", "Prev code", "Jump to previous code block"));
bindings.push(("Enter", "Run code", "Execute focused code block"));
bindings.push(("/", "Search", "Activate search")); bindings.push(("/", "Search", "Activate search"));
bindings.push(("Esc", "Clear", "Clear search")); bindings.push(("Esc", "Clear", "Clear search / deselect block"));
} }
Page::Dict => { Page::Dict => {
bindings.push(("Tab", "Focus", "Toggle category/words focus")); bindings.push(("Tab", "Focus", "Toggle category/words focus"));

View File

@@ -110,6 +110,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let midi_in_2 = midi_in_display(2); let midi_in_2 = midi_in_display(2);
let midi_in_3 = midi_in_display(3); let midi_in_3 = midi_in_display(3);
let onboarding_str = format!("{}/6 dismissed", app.ui.onboarding_dismissed.len());
let hue_str = format!("{}°", app.ui.hue_rotation as i32); let hue_str = format!("{}°", app.ui.hue_rotation as i32);
let lines: Vec<Line> = vec![ let lines: Vec<Line> = vec![
@@ -207,6 +208,15 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
render_option_line("Input 1", &midi_in_1, focus == OptionsFocus::MidiInput1, &theme), render_option_line("Input 1", &midi_in_1, focus == OptionsFocus::MidiInput1, &theme),
render_option_line("Input 2", &midi_in_2, focus == OptionsFocus::MidiInput2, &theme), render_option_line("Input 2", &midi_in_2, focus == OptionsFocus::MidiInput2, &theme),
render_option_line("Input 3", &midi_in_3, focus == OptionsFocus::MidiInput3, &theme), render_option_line("Input 3", &midi_in_3, focus == OptionsFocus::MidiInput3, &theme),
Line::from(""),
render_section_header("ONBOARDING", &theme),
render_divider(content_width, &theme),
render_option_line(
"Reset guides",
&onboarding_str,
focus == OptionsFocus::ResetOnboarding,
&theme,
),
]; ];
let total_lines = lines.len(); let total_lines = lines.len();

View File

@@ -4,7 +4,7 @@ use std::time::Duration;
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Alignment, 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, Cell, Clear, Padding, Paragraph, Row, Table}; use ratatui::widgets::{Block, Borders, Cell, Clear, Padding, Paragraph, Row, Table, Wrap};
use ratatui::Frame; use ratatui::Frame;
use crate::app::App; use crate::app::App;
@@ -418,13 +418,20 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
("Space", "Play"), ("Space", "Play"),
("?", "Keys"), ("?", "Keys"),
], ],
Page::Help => vec![ Page::Help => match app.ui.help_focus {
("↑↓", "Scroll"), crate::state::HelpFocus::Content => vec![
("Tab", "Topic"), ("n", "Next Example"),
("PgUp/Dn", "Page"), ("p", "Previous Example"),
("Enter", "Evaluate"),
("Tab", "Topics"),
],
crate::state::HelpFocus::Topics => vec![
("↑↓", "Navigate"),
("Tab", "Content"),
("/", "Search"), ("/", "Search"),
("?", "Keys"), ("?", "Keys"),
], ],
},
Page::Dict => vec![ Page::Dict => vec![
("Tab", "Focus"), ("Tab", "Focus"),
("↑↓", "Navigate"), ("↑↓", "Navigate"),
@@ -594,6 +601,86 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
inner inner
} }
Modal::Onboarding { page } => {
let pages = app.page.onboarding();
let page_idx = (*page).min(pages.len().saturating_sub(1));
let (desc, keys) = pages[page_idx];
let page_count = pages.len();
let text_width = 51usize;
let desc_lines = {
let mut lines = 0u16;
for line in desc.split('\n') {
let mut col = 0usize;
for word in line.split_whitespace() {
let wlen = word.len();
if col > 0 && col + 1 + wlen > text_width {
lines += 1;
col = wlen;
} else {
col += if col > 0 { 1 + wlen } else { wlen };
}
}
lines += 1;
}
lines
};
let key_lines = keys.len() as u16;
let modal_height = (3 + desc_lines + 1 + key_lines + 2).min(term.height.saturating_sub(4));
let title = if page_count > 1 {
format!(" {} ({}/{}) ", app.page.name(), page_idx + 1, page_count)
} else {
format!(" {} ", app.page.name())
};
let inner = ModalFrame::new(&title)
.width(57)
.height(modal_height)
.border_color(theme.modal.confirm)
.render_centered(frame, term);
let content_width = inner.width.saturating_sub(4);
let mut y = inner.y + 1;
let desc_area = Rect::new(inner.x + 2, y, content_width, desc_lines);
let body = Paragraph::new(desc)
.style(Style::new().fg(theme.ui.text_primary))
.wrap(Wrap { trim: true });
frame.render_widget(body, desc_area);
y += desc_lines + 1;
for &(key, action) in keys {
if y >= inner.y + inner.height - 1 {
break;
}
let line = Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{:>8}", key),
Style::new().fg(theme.hint.key),
),
Span::styled(
format!(" {action}"),
Style::new().fg(theme.hint.text),
),
]);
frame.render_widget(Paragraph::new(line), Rect::new(inner.x + 1, y, inner.width.saturating_sub(2), 1));
y += 1;
}
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
let mut hints_vec: Vec<(&str, &str)> = Vec::new();
if page_count > 1 {
hints_vec.push(("\u{2190}\u{2192}", "page"));
}
if app.page.help_topic_index().is_some() {
hints_vec.push(("?", "help"));
}
hints_vec.push(("Enter", "don't show again"));
let hints = hint_line(&hints_vec);
frame.render_widget(Paragraph::new(hints).alignment(Alignment::Center), hint_area);
inner
}
Modal::KeybindingsHelp { scroll } => render_modal_keybindings(frame, app, *scroll, term), Modal::KeybindingsHelp { scroll } => render_modal_keybindings(frame, app, *scroll, term),
Modal::EuclideanDistribution { Modal::EuclideanDistribution {
source_step, source_step,

View File

@@ -1,9 +1,8 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::Style; use ratatui::style::Style;
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph; use ratatui::widgets::{Cell, Paragraph, Row, Table};
use ratatui::Frame; use ratatui::Frame;
#[cfg(not(feature = "desktop"))]
use tui_big_text::{BigText, PixelSize}; use tui_big_text::{BigText, PixelSize};
use crate::state::ui::UiState; use crate::state::ui::UiState;
@@ -17,7 +16,6 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) {
let link_style = Style::new().fg(theme.title.link); let link_style = Style::new().fg(theme.title.link);
let license_style = Style::new().fg(theme.title.license); let license_style = Style::new().fg(theme.title.license);
#[cfg(not(feature = "desktop"))]
let big_title = BigText::builder() let big_title = BigText::builder()
.pixel_size(PixelSize::Full) .pixel_size(PixelSize::Full)
.style(Style::new().fg(theme.title.big_title).bold()) .style(Style::new().fg(theme.title.big_title).bold())
@@ -25,76 +23,111 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) {
.centered() .centered()
.build(); .build();
#[cfg(feature = "desktop")] let info_lines = vec![
let big_title = Paragraph::new(Line::from(Span::styled(
"CAGIRE",
Style::new().fg(theme.title.big_title).bold(),
)))
.alignment(Alignment::Center);
let version_style = Style::new().fg(theme.title.subtitle);
let subtitle_lines = vec![
Line::from(""), Line::from(""),
Line::from(Span::styled( Line::from(vec![
"A Forth Music Sequencer", Span::styled("A Forth Music Sequencer by ", Style::new().fg(theme.title.subtitle)),
Style::new().fg(theme.title.subtitle), Span::styled("BuboBubo", author_style),
)), ]),
Line::from(Span::styled( Line::from(Span::styled(
format!("v{}", env!("CARGO_PKG_VERSION")), format!("v{}", env!("CARGO_PKG_VERSION")),
version_style, Style::new().fg(theme.title.subtitle),
)), )),
Line::from(""), Line::from(""),
Line::from(Span::styled("by BuboBubo", author_style)),
Line::from(""),
Line::from(Span::styled("https://raphaelforment.fr", link_style)), Line::from(Span::styled("https://raphaelforment.fr", link_style)),
Line::from(""), Line::from(""),
Line::from(Span::styled("AGPL-3.0", license_style)), Line::from(Span::styled("AGPL-3.0", license_style)),
Line::from(""),
Line::from(""),
Line::from(Span::styled(
"Press any key to continue",
Style::new().fg(theme.title.prompt),
)),
]; ];
#[cfg(not(feature = "desktop"))] let keybindings = [
let big_text_height = 8; ("Ctrl+Arrows", "Navigate Views"),
#[cfg(feature = "desktop")] ("Enter", "Edit Step"),
let big_text_height = 1; ("Space", "Play/Stop"),
let min_title_width = 30; ("s", "Save"),
let subtitle_height = subtitle_lines.len() as u16; ("l", "Load"),
("q", "Quit"),
("?", "Keybindings"),
];
let key_style = Style::new().fg(theme.modal.confirm);
let desc_style = Style::new().fg(theme.ui.text_primary);
let rows: Vec<Row> = keybindings
.iter()
.enumerate()
.map(|(i, (key, desc))| {
let bg = if i % 2 == 0 {
theme.table.row_even
} else {
theme.table.row_odd
};
Row::new(vec![
Cell::from(*key).style(key_style),
Cell::from(*desc).style(desc_style),
])
.style(Style::new().bg(bg))
})
.collect();
let table = Table::new(
rows,
[Constraint::Length(14), Constraint::Fill(1)],
)
.column_spacing(2);
let press_line = Line::from(Span::styled(
"Press any key to continue",
Style::new().fg(theme.title.subtitle),
));
let info_height = info_lines.len() as u16;
let table_height = keybindings.len() as u16;
let table_width: u16 = 42;
let big_text_height: u16 = 8;
let content_height = info_height + table_height + 3; // +3 for gap + empty line + press line
let show_big_title = let show_big_title =
area.height >= (big_text_height + subtitle_height) && area.width >= min_title_width; area.height >= (big_text_height + content_height) && area.width >= 30;
let total_height = if show_big_title {
big_text_height + content_height
} else {
content_height
};
let v_pad = area.height.saturating_sub(total_height) / 2;
let mut constraints = Vec::new();
constraints.push(Constraint::Length(v_pad));
if show_big_title {
constraints.push(Constraint::Length(big_text_height));
}
constraints.push(Constraint::Length(info_height));
constraints.push(Constraint::Length(1)); // gap
constraints.push(Constraint::Length(table_height));
constraints.push(Constraint::Length(1)); // empty line
constraints.push(Constraint::Length(1)); // press any key
constraints.push(Constraint::Fill(1));
let areas = Layout::vertical(constraints).split(area);
let mut idx = 1; // skip padding
if show_big_title { if show_big_title {
let total_height = big_text_height + subtitle_height; frame.render_widget(big_title, areas[idx]);
let vertical_padding = area.height.saturating_sub(total_height) / 2; idx += 1;
let [_, title_area, subtitle_area, _] = Layout::vertical([
Constraint::Length(vertical_padding),
Constraint::Length(big_text_height),
Constraint::Length(subtitle_height),
Constraint::Fill(1),
])
.areas(area);
frame.render_widget(big_title, title_area);
let subtitle = Paragraph::new(subtitle_lines).alignment(Alignment::Center);
frame.render_widget(subtitle, subtitle_area);
} else {
let vertical_padding = area.height.saturating_sub(subtitle_height) / 2;
let [_, subtitle_area, _] = Layout::vertical([
Constraint::Length(vertical_padding),
Constraint::Length(subtitle_height),
Constraint::Fill(1),
])
.areas(area);
let subtitle = Paragraph::new(subtitle_lines).alignment(Alignment::Center);
frame.render_widget(subtitle, subtitle_area);
} }
let info = Paragraph::new(info_lines).alignment(Alignment::Center);
frame.render_widget(info, areas[idx]);
idx += 1;
idx += 1; // skip gap
let tw = table_width.min(areas[idx].width);
let tx = areas[idx].x + (areas[idx].width.saturating_sub(tw)) / 2;
let table_area = Rect::new(tx, areas[idx].y, tw, areas[idx].height);
frame.render_widget(table, table_area);
idx += 2; // skip empty line
let press = Paragraph::new(press_line).alignment(Alignment::Center);
frame.render_widget(press, areas[idx]);
} }

View File

@@ -60,3 +60,6 @@ mod chords;
#[path = "forth/euclidean.rs"] #[path = "forth/euclidean.rs"]
mod euclidean; mod euclidean;
#[path = "forth/case_statement.rs"]
mod case_statement;

View File

@@ -0,0 +1,108 @@
use super::harness::*;
#[test]
fn single_clause_match() {
expect_int("1 case 1 of 42 endof endcase", 42);
}
#[test]
fn single_clause_no_match() {
let f = run("1 case 2 of 42 endof endcase");
assert!(f.stack().is_empty());
}
#[test]
fn multi_clause_first() {
expect_int("0 case 0 of 10 endof 1 of 20 endof 2 of 30 endof endcase", 10);
}
#[test]
fn multi_clause_middle() {
expect_int("1 case 0 of 10 endof 1 of 20 endof 2 of 30 endof endcase", 20);
}
#[test]
fn multi_clause_last() {
expect_int("2 case 0 of 10 endof 1 of 20 endof 2 of 30 endof endcase", 30);
}
#[test]
fn no_match_stack_clean() {
let f = run("99 case 0 of 10 endof 1 of 20 endof endcase");
assert!(f.stack().is_empty());
}
#[test]
fn expression_test_value() {
expect_int("3 case 1 2 + of 42 endof endcase", 42);
}
#[test]
fn expression_case_value() {
expect_int("1 2 + case 3 of 42 endof endcase", 42);
}
#[test]
fn body_with_computation() {
expect_int("1 case 1 of 10 20 + endof endcase", 30);
}
#[test]
fn default_clause() {
// Default drops case value and pushes replacement twice (endcase drops one)
expect_int("99 case 0 of 10 endof 1 of 20 endof drop 42 42 endcase", 42);
}
#[test]
fn default_clause_uses_case_value() {
// dup preserves case value through endcase's drop
expect_int("99 case 0 of 10 endof dup endcase", 99);
}
#[test]
fn default_clause_not_reached_on_match() {
expect_int("0 case 0 of 10 endof 999 endcase", 10);
}
#[test]
fn nested_case_in_case() {
// outer matches 1, inner matches 2
expect_int("1 case 1 of 2 case 2 of 42 endof endcase endof endcase", 42);
}
#[test]
fn if_inside_case_body() {
expect_int("1 case 1 of 1 if 42 else 99 then endof endcase", 42);
}
#[test]
fn case_inside_if_body() {
expect_int("1 if 2 case 2 of 42 endof endcase then", 42);
}
#[test]
fn preserves_stack_below() {
let f = run("100 1 case 1 of 42 endof endcase");
let stack = f.stack();
assert_eq!(stack.len(), 2);
}
#[test]
fn missing_endcase() {
expect_error("1 case 0 of 10 endof", "missing 'endcase'");
}
#[test]
fn stray_of() {
expect_error("1 of", "unexpected 'of'");
}
#[test]
fn stray_endof() {
expect_error("1 endof", "unexpected 'endof'");
}
#[test]
fn stray_endcase() {
expect_error("1 endcase", "unexpected 'endcase'");
}

View File

@@ -230,6 +230,52 @@ fn test_midi_continue() {
assert_eq!(outputs[0], "/midi/continue/dev/0"); assert_eq!(outputs[0], "/midi/continue/dev/0");
} }
// at (delta) tests
#[test]
fn test_midi_at_single_delta() {
let outputs = expect_outputs("0.5 at 60 note m.", 1);
assert!(outputs[0].contains("/note/60/"));
assert!(outputs[0].contains("/delta/"));
}
#[test]
fn test_midi_at_multiple_deltas() {
let outputs = expect_outputs("0 0.5 at 60 note m.", 2);
assert!(outputs[0].contains("/note/60/"));
assert!(outputs[1].contains("/note/60/"));
assert!(outputs[1].contains("/delta/"));
}
#[test]
fn test_midi_at_with_polyphony() {
// 2 notes × 2 deltas = 4 events
expect_outputs("0 0.5 at 60 64 note m.", 4);
}
#[test]
fn test_midi_arp_notes() {
let outputs = expect_outputs("c4 e4 g4 arp note m.", 3);
assert!(outputs[0].contains("/note/60/"));
assert!(outputs[1].contains("/note/64/"));
assert!(outputs[2].contains("/note/67/"));
}
#[test]
fn test_midi_arp_with_at() {
let outputs = expect_outputs("0 0.25 0.5 at c4 e4 g4 arp note m.", 3);
assert!(outputs[0].contains("/note/60/"));
assert!(outputs[1].contains("/note/64/"));
assert!(outputs[2].contains("/note/67/"));
}
#[test]
fn test_midi_at_cc() {
let outputs = expect_outputs("0 0.5 at 1 ccnum 64 ccout m.", 2);
assert!(outputs[0].contains("/midi/cc/1/64/"));
assert!(outputs[1].contains("/midi/cc/1/64/"));
assert!(outputs[1].contains("/delta/"));
}
// Test message type priority (first matching type wins) // Test message type priority (first matching type wins)
#[test] #[test]
fn test_midi_message_priority_cc_over_note() { fn test_midi_message_priority_cc_over_note() {
@@ -286,3 +332,34 @@ fn test_midi_note_duration_with_speed() {
let outputs = f.evaluate("60 note m.", &ctx).unwrap(); let outputs = f.evaluate("60 note m.", &ctx).unwrap();
assert!(outputs[0].contains("/dur/0.0625")); assert!(outputs[0].contains("/dur/0.0625"));
} }
// Polyphonic MIDI tests
#[test]
fn test_midi_polyphonic_notes() {
let outputs = expect_outputs("60 64 67 note m.", 3);
assert!(outputs[0].contains("/midi/note/60/"));
assert!(outputs[1].contains("/midi/note/64/"));
assert!(outputs[2].contains("/midi/note/67/"));
}
#[test]
fn test_midi_polyphonic_notes_with_velocity() {
let outputs = expect_outputs("60 64 67 note 100 80 60 velocity m.", 3);
assert!(outputs[0].contains("/note/60/vel/100/"));
assert!(outputs[1].contains("/note/64/vel/80/"));
assert!(outputs[2].contains("/note/67/vel/60/"));
}
#[test]
fn test_midi_polyphonic_channel() {
let outputs = expect_outputs("60 note 1 2 chan m.", 2);
assert!(outputs[0].contains("/note/60/") && outputs[0].contains("/chan/0"));
assert!(outputs[1].contains("/note/60/") && outputs[1].contains("/chan/1"));
}
#[test]
fn test_midi_polyphonic_cc() {
let outputs = expect_outputs("1 2 ccnum 64 127 ccout m.", 2);
assert!(outputs[0].contains("/midi/cc/1/64/"));
assert!(outputs[1].contains("/midi/cc/2/127/"));
}

View File

@@ -42,3 +42,28 @@ fn float_var() {
fn increment_pattern() { fn increment_pattern() {
expect_int(r#"0 !n @n 1 + !n @n 1 + !n @n"#, 2); expect_int(r#"0 !n @n 1 + !n @n 1 + !n @n"#, 2);
} }
#[test]
fn set_keep() {
expect_int(r#"42 ,x"#, 42);
}
#[test]
fn set_keep_stores() {
let f = forth();
let ctx = default_ctx();
f.evaluate(r#"42 ,x"#, &ctx).unwrap();
f.clear_stack();
f.evaluate(r#"@x"#, &ctx).unwrap();
assert_eq!(stack_int(&f), 42);
}
#[test]
fn set_keep_chain() {
let f = forth();
let ctx = default_ctx();
f.evaluate(r#"10 ,a ,b"#, &ctx).unwrap();
f.clear_stack();
f.evaluate(r#"@a @b +"#, &ctx).unwrap();
assert_eq!(stack_int(&f), 20);
}