Feat: UI / UX
This commit is contained in:
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: ":",
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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
185
docs/control_flow.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
71
docs/tutorial_variables.md
Normal file
71
docs/tutorial_variables.md
Normal 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.
|
||||||
51
src/app.rs
51
src/app.rs
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
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,17 @@ 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();
|
||||||
|
}
|
||||||
|
|
||||||
// Prelude
|
// Prelude
|
||||||
AppCommand::OpenPreludeEditor => self.open_prelude_editor(),
|
AppCommand::OpenPreludeEditor => self.open_prelude_editor(),
|
||||||
AppCommand::SavePrelude => self.save_prelude(),
|
AppCommand::SavePrelude => self.save_prelude(),
|
||||||
|
|||||||
@@ -143,6 +143,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 +165,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 +186,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 +422,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 +467,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 +509,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(),
|
||||||
|
|||||||
@@ -267,4 +267,8 @@ pub enum AppCommand {
|
|||||||
SavePrelude,
|
SavePrelude,
|
||||||
EvaluatePrelude,
|
EvaluatePrelude,
|
||||||
ClosePreludeEditor,
|
ClosePreludeEditor,
|
||||||
|
|
||||||
|
// Onboarding
|
||||||
|
DismissOnboarding,
|
||||||
|
ResetOnboarding,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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 }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') => {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -525,6 +525,14 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Modal::Onboarding => match key.code {
|
||||||
|
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
|
||||||
|
|||||||
@@ -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 }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -68,6 +69,7 @@ pub const DOCS: &[DocEntry] = &[
|
|||||||
include_str!("../../docs/tutorial_generators.md"),
|
include_str!("../../docs/tutorial_generators.md"),
|
||||||
),
|
),
|
||||||
Topic("Timing with at", include_str!("../../docs/tutorial_at.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 {
|
||||||
|
|||||||
68
src/page.rs
68
src/page.rs
@@ -91,4 +91,72 @@ impl Page {
|
|||||||
*self = page;
|
*self = page;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const fn onboarding(self) -> (&'static str, &'static [(&'static str, &'static str)]) {
|
||||||
|
match self {
|
||||||
|
Page::Main => (
|
||||||
|
"The step sequencer. Each cell holds a Forth script. When playing, active steps are evaluated in order to produce sound. The grid shows step numbers, names, link indicators, and highlights the currently playing step.",
|
||||||
|
&[
|
||||||
|
("Arrows", "navigate"),
|
||||||
|
("Enter", "edit script"),
|
||||||
|
("Space", "play/stop"),
|
||||||
|
("t", "toggle step"),
|
||||||
|
("p", "preview"),
|
||||||
|
("Tab", "samples"),
|
||||||
|
("?", "all keys"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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, mute them, solo them, change their settings. Stage / commit system 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"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,4 +90,5 @@ pub enum Modal {
|
|||||||
steps: String,
|
steps: String,
|
||||||
rotation: String,
|
rotation: String,
|
||||||
},
|
},
|
||||||
|
Onboarding,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,7 +183,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 +209,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()
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str
|
|||||||
("F1–F6", "Go to view", "Dict/Patterns/Options/Help/Sequencer/Engine"),
|
("F1–F6", "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"));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"),
|
||||||
("/", "Search"),
|
("Enter", "Evaluate"),
|
||||||
("?", "Keys"),
|
("Tab", "Topics"),
|
||||||
],
|
],
|
||||||
|
crate::state::HelpFocus::Topics => vec![
|
||||||
|
("↑↓", "Navigate"),
|
||||||
|
("Tab", "Content"),
|
||||||
|
("/", "Search"),
|
||||||
|
("?", "Keys"),
|
||||||
|
],
|
||||||
|
},
|
||||||
Page::Dict => vec![
|
Page::Dict => vec![
|
||||||
("Tab", "Focus"),
|
("Tab", "Focus"),
|
||||||
("↑↓", "Navigate"),
|
("↑↓", "Navigate"),
|
||||||
@@ -594,6 +601,60 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
|||||||
|
|
||||||
inner
|
inner
|
||||||
}
|
}
|
||||||
|
Modal::Onboarding => {
|
||||||
|
let (desc, keys) = app.page.onboarding();
|
||||||
|
let text_width = 51usize; // inner width minus 2 for padding
|
||||||
|
let desc_lines = {
|
||||||
|
let mut lines = 0u16;
|
||||||
|
for line in desc.split('\n') {
|
||||||
|
lines += (line.len() as u16).max(1).div_ceil(text_width as u16);
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
};
|
||||||
|
let key_lines = keys.len() as u16;
|
||||||
|
let modal_height = (3 + desc_lines + 1 + key_lines + 2).min(term.height.saturating_sub(4)); // border + pad + desc + gap + keys + pad + hint
|
||||||
|
|
||||||
|
let inner = ModalFrame::new(&format!(" {} ", app.page.name()))
|
||||||
|
.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 hints = hint_line(&[("Enter", "don't show again"), ("any key", "dismiss")]);
|
||||||
|
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,
|
||||||
|
|||||||
@@ -52,9 +52,32 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) {
|
|||||||
Line::from(Span::styled("AGPL-3.0", license_style)),
|
Line::from(Span::styled("AGPL-3.0", license_style)),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Ctrl+Arrows", Style::new().fg(theme.title.link)),
|
||||||
|
Span::styled(": navigate views", Style::new().fg(theme.title.prompt)),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Enter", Style::new().fg(theme.title.link)),
|
||||||
|
Span::styled(": edit step ", Style::new().fg(theme.title.prompt)),
|
||||||
|
Span::styled("Space", Style::new().fg(theme.title.link)),
|
||||||
|
Span::styled(": play/stop", Style::new().fg(theme.title.prompt)),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("s", Style::new().fg(theme.title.link)),
|
||||||
|
Span::styled(": save ", Style::new().fg(theme.title.prompt)),
|
||||||
|
Span::styled("l", Style::new().fg(theme.title.link)),
|
||||||
|
Span::styled(": load ", Style::new().fg(theme.title.prompt)),
|
||||||
|
Span::styled("q", Style::new().fg(theme.title.link)),
|
||||||
|
Span::styled(": quit", Style::new().fg(theme.title.prompt)),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("?", Style::new().fg(theme.title.link)),
|
||||||
|
Span::styled(": keybindings", Style::new().fg(theme.title.prompt)),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
"Press any key to continue",
|
"Press any key to continue",
|
||||||
Style::new().fg(theme.title.prompt),
|
Style::new().fg(theme.title.subtitle),
|
||||||
)),
|
)),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
108
tests/forth/case_statement.rs
Normal file
108
tests/forth/case_statement.rs
Normal 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'");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user