Compare commits
3 Commits
670ae0b6b6
...
5385bf675a
| Author | SHA1 | Date | |
|---|---|---|---|
| 5385bf675a | |||
| 211e71f5a9 | |||
| 23c7abb145 |
@@ -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 }
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -1231,47 +1241,115 @@ impl Forth {
|
|||||||
// MIDI operations
|
// MIDI operations
|
||||||
Op::MidiEmit => {
|
Op::MidiEmit => {
|
||||||
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
|
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
|
||||||
let get_int = |name: &str| -> Option<i64> {
|
|
||||||
params
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|(k, _)| *k == name)
|
|
||||||
.and_then(|(_, v)| v.as_int().ok())
|
|
||||||
};
|
|
||||||
let get_float = |name: &str| -> Option<f64> {
|
|
||||||
params
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find(|(k, _)| *k == name)
|
|
||||||
.and_then(|(_, v)| v.as_float().ok())
|
|
||||||
};
|
|
||||||
let chan = get_int("chan")
|
|
||||||
.map(|c| (c.clamp(1, 16) - 1) as u8)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let dev = get_int("dev").map(|d| d.clamp(0, 3) as u8).unwrap_or(0);
|
|
||||||
|
|
||||||
if let (Some(cc), Some(val)) = (get_int("ccnum"), get_int("ccout")) {
|
// Build schedule: (emit_idx, delta_secs) — same logic as Op::Emit
|
||||||
let cc = cc.clamp(0, 127) as u8;
|
let schedule: Vec<(usize, f64)> = if has_arp_list(cmd) {
|
||||||
let val = val.clamp(0, 127) as u8;
|
let arp_count = compute_arp_count(cmd);
|
||||||
outputs.push(format!("/midi/cc/{cc}/{val}/chan/{chan}/dev/{dev}"));
|
let explicit = !cmd.deltas().is_empty();
|
||||||
} else if let Some(bend) = get_float("bend") {
|
let delta_list = cmd.deltas();
|
||||||
let bend_clamped = bend.clamp(-1.0, 1.0);
|
let count = if explicit {
|
||||||
let bend_14bit = ((bend_clamped + 1.0) * 8191.5) as u16;
|
arp_count.max(delta_list.len())
|
||||||
outputs.push(format!("/midi/bend/{bend_14bit}/chan/{chan}/dev/{dev}"));
|
} else {
|
||||||
} else if let Some(pressure) = get_int("pressure") {
|
arp_count
|
||||||
let pressure = pressure.clamp(0, 127) as u8;
|
};
|
||||||
outputs.push(format!("/midi/pressure/{pressure}/chan/{chan}/dev/{dev}"));
|
(0..count)
|
||||||
} else if let Some(program) = get_int("program") {
|
.map(|i| {
|
||||||
let program = program.clamp(0, 127) as u8;
|
let delta_secs = if explicit {
|
||||||
outputs.push(format!("/midi/program/{program}/chan/{chan}/dev/{dev}"));
|
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 {
|
} else {
|
||||||
let note = get_int("note").unwrap_or(60).clamp(0, 127) as u8;
|
let poly_count = compute_poly_count(cmd);
|
||||||
let velocity = get_int("velocity").unwrap_or(100).clamp(0, 127) as u8;
|
let deltas: Vec<f64> = if cmd.deltas().is_empty() {
|
||||||
let dur = get_float("dur").unwrap_or(1.0);
|
vec![0.0]
|
||||||
let dur_secs = dur * ctx.step_duration();
|
} else {
|
||||||
outputs.push(format!(
|
cmd.deltas()
|
||||||
"/midi/note/{note}/vel/{velocity}/chan/{chan}/dur/{dur_secs}/dev/{dev}"
|
.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> {
|
||||||
|
params
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|(k, _)| *k == name)
|
||||||
|
.and_then(|(_, v)| {
|
||||||
|
resolve_cycling(v, emit_idx).as_int().ok()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let get_float = |name: &str| -> Option<f64> {
|
||||||
|
params
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|(k, _)| *k == name)
|
||||||
|
.and_then(|(_, v)| {
|
||||||
|
resolve_cycling(v, emit_idx).as_float().ok()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let chan = get_int("chan")
|
||||||
|
.map(|c| (c.clamp(1, 16) - 1) as u8)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let dev =
|
||||||
|
get_int("dev").map(|d| d.clamp(0, 3) as u8).unwrap_or(0);
|
||||||
|
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")) {
|
||||||
|
let cc = cc.clamp(0, 127) as u8;
|
||||||
|
let val = val.clamp(0, 127) as u8;
|
||||||
|
outputs.push(format!(
|
||||||
|
"/midi/cc/{cc}/{val}/chan/{chan}/dev/{dev}{delta_suffix}"
|
||||||
|
));
|
||||||
|
} else if let Some(bend) = get_float("bend") {
|
||||||
|
let bend_clamped = bend.clamp(-1.0, 1.0);
|
||||||
|
let bend_14bit = ((bend_clamped + 1.0) * 8191.5) as u16;
|
||||||
|
outputs.push(format!(
|
||||||
|
"/midi/bend/{bend_14bit}/chan/{chan}/dev/{dev}{delta_suffix}"
|
||||||
|
));
|
||||||
|
} else if let Some(pressure) = get_int("pressure") {
|
||||||
|
let pressure = pressure.clamp(0, 127) as u8;
|
||||||
|
outputs.push(format!(
|
||||||
|
"/midi/pressure/{pressure}/chan/{chan}/dev/{dev}{delta_suffix}"
|
||||||
|
));
|
||||||
|
} else if let Some(program) = get_int("program") {
|
||||||
|
let program = program.clamp(0, 127) as u8;
|
||||||
|
outputs.push(format!(
|
||||||
|
"/midi/program/{program}/chan/{chan}/dev/{dev}{delta_suffix}"
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
let note = get_int("note").unwrap_or(60).clamp(0, 127) as u8;
|
||||||
|
let velocity =
|
||||||
|
get_int("velocity").unwrap_or(100).clamp(0, 127) as u8;
|
||||||
|
let dur = get_float("dur").unwrap_or(1.0);
|
||||||
|
let dur_secs = dur * ctx.step_duration();
|
||||||
|
outputs.push(format!(
|
||||||
|
"/midi/note/{note}/vel/{velocity}/chan/{chan}/dur/{dur_secs}/dev/{dev}{delta_suffix}"
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Op::MidiClock => {
|
Op::MidiClock => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
100
docs/tutorial_at.md
Normal file
100
docs/tutorial_at.md
Normal 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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
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.
|
||||||
56
src/app.rs
56
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 { 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(),
|
||||||
|
|||||||
267
src/bin/desktop/block_renderer.rs
Normal file
267
src/bin/desktop/block_renderer.rs
Normal 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,
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -267,4 +267,9 @@ pub enum AppCommand {
|
|||||||
SavePrelude,
|
SavePrelude,
|
||||||
EvaluatePrelude,
|
EvaluatePrelude,
|
||||||
ClosePreludeEditor,
|
ClosePreludeEditor,
|
||||||
|
|
||||||
|
// Onboarding
|
||||||
|
DismissOnboarding,
|
||||||
|
ResetOnboarding,
|
||||||
|
GoToHelpTopic(usize),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 {
|
||||||
|
|||||||
106
src/page.rs
106
src/page.rs
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { page: usize },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'");
|
||||||
|
}
|
||||||
@@ -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/"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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