diff --git a/crates/forth/src/compiler.rs b/crates/forth/src/compiler.rs index 1817d9d..acc144d 100644 --- a/crates/forth/src/compiler.rs +++ b/crates/forth/src/compiler.rs @@ -160,6 +160,12 @@ fn compile(tokens: &[Token], dict: &Dictionary) -> Result, String> { ops.push(Op::Branch(else_ops.len())); 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) { 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)) } + +fn compile_case(tokens: &[Token], dict: &Dictionary) -> Result<(Vec, 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 = 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)) +} diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index 7a959fa..af0cad5 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -64,6 +64,7 @@ pub enum Op { Emit, Get, Set, + SetKeep, GetContext(&'static str), Rand(Option), ExpRand(Option), diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index eddc707..c73c0cb 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -637,6 +637,16 @@ impl Forth { .expect("var_writes taken") .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) => { let val = match *name { diff --git a/crates/forth/src/words/compile.rs b/crates/forth/src/words/compile.rs index e2e1b5b..8d4040c 100644 --- a/crates/forth/src/words/compile.rs +++ b/crates/forth/src/words/compile.rs @@ -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) { ops.push(Op::PushInt(midi, span)); return true; diff --git a/crates/forth/src/words/core.rs b/crates/forth/src/words/core.rs index 81fe969..c6ee441 100644 --- a/crates/forth/src/words/core.rs +++ b/crates/forth/src/words/core.rs @@ -558,6 +558,16 @@ pub(super) const WORDS: &[Word] = &[ compile: Simple, varargs: false, }, + Word { + name: ",", + aliases: &[], + category: "Variables", + stack: "(val -- val)", + desc: "Store value in variable, keep on stack", + example: "440 ,freq => 440", + compile: Simple, + varargs: false, + }, // Definitions Word { name: ":", diff --git a/crates/markdown/src/lib.rs b/crates/markdown/src/lib.rs index 403aa11..54c484f 100644 --- a/crates/markdown/src/lib.rs +++ b/crates/markdown/src/lib.rs @@ -3,5 +3,5 @@ mod parser; mod theme; pub use highlighter::{CodeHighlighter, NoHighlight}; -pub use parser::parse; +pub use parser::{parse, CodeBlock, ParsedMarkdown}; pub use theme::{DefaultTheme, MarkdownTheme}; diff --git a/crates/markdown/src/parser.rs b/crates/markdown/src/parser.rs index f3ea5bc..5a8d3ad 100644 --- a/crates/markdown/src/parser.rs +++ b/crates/markdown/src/parser.rs @@ -5,17 +5,31 @@ use ratatui::text::{Line as RLine, Span}; use crate::highlighter::CodeHighlighter; use crate::theme::MarkdownTheme; +pub struct CodeBlock { + pub start_line: usize, + pub end_line: usize, + pub source: String, +} + +pub struct ParsedMarkdown { + pub lines: Vec>, + pub code_blocks: Vec, +} + pub fn parse( md: &str, theme: &T, highlighter: &H, -) -> Vec> { +) -> ParsedMarkdown { let processed = preprocess_markdown(md); let text = minimad::Text::from(processed.as_str()); let mut lines = Vec::new(); let mut code_line_nr: usize = 0; let mut table_buffer: Vec = Vec::new(); + let mut code_blocks: Vec = Vec::new(); + let mut current_block_start: Option = None; + let mut current_block_source: Vec = Vec::new(); let flush_table = |buf: &mut Vec, out: &mut Vec>, theme: &T| { if buf.is_empty() { @@ -27,16 +41,43 @@ pub fn parse( } }; + let close_block = |start: Option, + source: &mut Vec, + blocks: &mut Vec, + lines: &Vec>| { + 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 { + 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 { Line::Normal(composite) if composite.style == CompositeStyle::Code => { flush_table(&mut table_buffer, &mut lines, theme); code_line_nr += 1; + if current_block_start.is_none() { + current_block_start = Some(lines.len()); + } let raw: String = composite .compounds .iter() .map(|c: &minimad::Compound| c.src) .collect(); + current_block_source.push(raw.clone()); let mut spans = vec![ Span::styled(format!(" {code_line_nr:>2} "), theme.code_border()), Span::styled("│ ", theme.code_border()), @@ -66,9 +107,15 @@ pub fn parse( } } } + close_block( + current_block_start.take(), + &mut current_block_source, + &mut code_blocks, + &lines, + ); flush_table(&mut table_buffer, &mut lines, theme); - lines + ParsedMarkdown { lines, code_blocks } } pub fn preprocess_markdown(md: &str) -> String { @@ -300,28 +347,39 @@ mod tests { #[test] fn test_parse_headings() { let md = "# H1\n## H2\n### H3"; - let lines = parse(md, &DefaultTheme, &NoHighlight); - assert_eq!(lines.len(), 3); + let parsed = parse(md, &DefaultTheme, &NoHighlight); + assert_eq!(parsed.lines.len(), 3); } #[test] fn test_parse_code_block() { let md = "```\ncode line\n```"; - let lines = parse(md, &DefaultTheme, &NoHighlight); - assert!(!lines.is_empty()); + let parsed = parse(md, &DefaultTheme, &NoHighlight); + assert!(!parsed.lines.is_empty()); + assert_eq!(parsed.code_blocks.len(), 1); + assert_eq!(parsed.code_blocks[0].source, "code line"); } #[test] fn test_parse_table() { let md = "| A | B |\n|---|---|\n| 1 | 2 |"; - let lines = parse(md, &DefaultTheme, &NoHighlight); - assert_eq!(lines.len(), 2); + let parsed = parse(md, &DefaultTheme, &NoHighlight); + assert_eq!(parsed.lines.len(), 2); } #[test] fn test_default_theme_works() { let md = "Hello **world**"; - let lines = parse(md, &DefaultTheme, &NoHighlight); - assert_eq!(lines.len(), 1); + let parsed = parse(md, &DefaultTheme, &NoHighlight); + 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"); } } diff --git a/docs/control_flow.md b/docs/control_flow.md new file mode 100644 index 0000000..c310aa3 --- /dev/null +++ b/docs/control_flow.md @@ -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. diff --git a/docs/oddities.md b/docs/oddities.md index d6c731b..01bfc6f 100644 --- a/docs/oddities.md +++ b/docs/oddities.md @@ -89,9 +89,10 @@ Cagire uses prefix syntax: ```forth 10 !x ;; store 10 in x @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 diff --git a/docs/tutorial_variables.md b/docs/tutorial_variables.md new file mode 100644 index 0000000..d602a3e --- /dev/null +++ b/docs/tutorial_variables.md @@ -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. diff --git a/src/app.rs b/src/app.rs index 71c16e4..798012c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -134,6 +134,7 @@ impl App { color_scheme: self.ui.color_scheme, layout: self.audio.config.layout, hue_rotation: self.ui.hue_rotation, + onboarding_dismissed: self.ui.onboarding_dismissed.clone(), ..Default::default() }, link: crate::settings::LinkSettings { @@ -1117,6 +1118,17 @@ impl App { UndoEntry { scope: reverse_scope, cursor } } + pub fn maybe_show_onboarding(&mut self) { + if self.ui.modal != Modal::None { + return; + } + let name = self.page.name(); + if self.ui.onboarding_dismissed.iter().any(|d| d == name) { + return; + } + self.ui.modal = Modal::Onboarding; + } + pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) { // Handle undo/redo before the undoable snapshot match cmd { @@ -1342,11 +1354,26 @@ impl App { } // Page navigation - AppCommand::PageLeft => self.page.left(), - AppCommand::PageRight => self.page.right(), - AppCommand::PageUp => self.page.up(), - AppCommand::PageDown => self.page.down(), - AppCommand::GoToPage(page) => self.page = page, + AppCommand::PageLeft => { + self.page.left(); + self.maybe_show_onboarding(); + } + 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 AppCommand::HelpToggleFocus => help_nav::toggle_focus(&mut self.ui), @@ -1391,9 +1418,11 @@ impl App { self.select_edit_bank(bank); self.select_edit_pattern(pattern); self.page.down(); + self.maybe_show_onboarding(); } AppCommand::PatternsBack => { self.page.down(); + self.maybe_show_onboarding(); } // Mute/Solo (staged) @@ -1418,6 +1447,7 @@ impl App { } AppCommand::HideTitle => { self.ui.show_title = false; + self.maybe_show_onboarding(); } AppCommand::ToggleEditorStack => { self.editor_ctx.show_stack = !self.editor_ctx.show_stack; @@ -1612,6 +1642,17 @@ impl App { ); } + // Onboarding + AppCommand::DismissOnboarding => { + let name = self.page.name().to_string(); + if !self.ui.onboarding_dismissed.contains(&name) { + self.ui.onboarding_dismissed.push(name); + } + } + AppCommand::ResetOnboarding => { + self.ui.onboarding_dismissed.clear(); + } + // Prelude AppCommand::OpenPreludeEditor => self.open_prelude_editor(), AppCommand::SavePrelude => self.save_prelude(), diff --git a/src/bin/desktop.rs b/src/bin/desktop.rs index b666e1f..0b7393f 100644 --- a/src/bin/desktop.rs +++ b/src/bin/desktop.rs @@ -143,6 +143,10 @@ struct CagireDesktop { _analysis_handle: Option, midi_rx: Receiver, current_font: FontChoice, + zoom_factor: f32, + fullscreen: bool, + decorations: bool, + always_on_top: bool, mouse_x: Arc, mouse_y: Arc, mouse_down: Arc, @@ -161,8 +165,10 @@ impl CagireDesktop { let current_font = FontChoice::from_setting(&b.settings.display.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_zoom_factor(zoom_factor); Self { app: b.app, @@ -180,6 +186,10 @@ impl CagireDesktop { _analysis_handle: b.analysis_handle, midi_rx: b.midi_rx, current_font, + zoom_factor, + fullscreen: false, + decorations: true, + always_on_top: false, mouse_x: b.mouse_x, mouse_y: b.mouse_y, mouse_down: b.mouse_down, @@ -412,7 +422,15 @@ impl eframe::App for CagireDesktop { } 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_zoom = None; + let mut toggle_fullscreen = false; + let mut toggle_decorations = false; + let mut toggle_always_on_top = false; egui::CentralPanel::default() .frame(egui::Frame::NONE.fill(egui::Color32::BLACK)) @@ -449,6 +467,38 @@ impl eframe::App for CagireDesktop { } } }); + ui.menu_button("Zoom", |ui| { + for &level in &[0.5_f32, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] { + let selected = (current_zoom - level).abs() < 0.01; + let label = format!("{:.0}%", level * 100.0); + if ui.selectable_label(selected, label).clicked() { + new_zoom = Some(level); + ui.close(); + } + } + }); + ui.separator(); + if ui + .selectable_label(current_fullscreen, "Fullscreen") + .clicked() + { + toggle_fullscreen = true; + ui.close(); + } + if ui + .selectable_label(current_always_on_top, "Always On Top") + .clicked() + { + toggle_always_on_top = true; + ui.close(); + } + if ui + .selectable_label(!current_decorations, "Borderless") + .clicked() + { + toggle_decorations = true; + ui.close(); + } }); }); @@ -459,6 +509,30 @@ impl eframe::App for CagireDesktop { settings.display.font = font.to_setting().to_string(); settings.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( self.app.audio.config.refresh_rate.millis(), diff --git a/src/commands.rs b/src/commands.rs index 459d303..ecd2abf 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -267,4 +267,8 @@ pub enum AppCommand { SavePrelude, EvaluatePrelude, ClosePreludeEditor, + + // Onboarding + DismissOnboarding, + ResetOnboarding, } diff --git a/src/init.rs b/src/init.rs index 5aa6934..eab882f 100644 --- a/src/init.rs +++ b/src/init.rs @@ -86,6 +86,7 @@ pub fn init(args: InitArgs) -> Init { app.ui.color_scheme = settings.display.color_scheme; app.ui.hue_rotation = settings.display.hue_rotation; 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 rotated = diff --git a/src/input/engine_page.rs b/src/input/engine_page.rs index 4fd3e53..e290510 100644 --- a/src/input/engine_page.rs +++ b/src/input/engine_page.rs @@ -158,6 +158,8 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input time: None, }); } + KeyCode::Char('s') => super::open_save(ctx), + KeyCode::Char('l') => super::open_load(ctx), KeyCode::Char('?') => { ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); } diff --git a/src/input/help_page.rs b/src/input/help_page.rs index 79938cc..2364698 100644 --- a/src/input/help_page.rs +++ b/src/input/help_page.rs @@ -3,7 +3,7 @@ use std::sync::atomic::Ordering; use super::{InputContext, InputResult}; 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 { 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() => { 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::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 => { ctx.dispatch(AppCommand::HelpNextTopic(5)); } @@ -49,6 +64,8 @@ pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputRe selected: false, })); } + KeyCode::Char('s') => super::open_save(ctx), + KeyCode::Char('l') => super::open_load(ctx), KeyCode::Char('?') => { 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 } +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::>() + .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 { 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, })); } + KeyCode::Char('s') => super::open_save(ctx), + KeyCode::Char('l') => super::open_load(ctx), KeyCode::Char('?') => { ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); } diff --git a/src/input/main_page.rs b/src/input/main_page.rs index c783e88..9c31237 100644 --- a/src/input/main_page.rs +++ b/src/input/main_page.rs @@ -84,18 +84,7 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool ctx.dispatch(AppCommand::OpenModal(Modal::Editor)); } KeyCode::Char('t') => ctx.dispatch(AppCommand::ToggleSteps), - KeyCode::Char('s') => { - 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('s') => super::open_save(ctx), KeyCode::Char('z') if ctrl && !shift => { 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); } KeyCode::Char('h') if ctrl => ctx.dispatch(AppCommand::HardenSteps), - KeyCode::Char('l') => { - 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('l') => super::open_load(ctx), KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp), KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown), KeyCode::Char('T') => { diff --git a/src/input/mod.rs b/src/input/mod.rs index 55fce72..bd4da17 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -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) { let paths = ctx.app.project_state.project.sample_paths.clone(); if paths.is_empty() { diff --git a/src/input/modal.rs b/src/input/modal.rs index bc40a56..eae94b0 100644 --- a/src/input/modal.rs +++ b/src/input/modal.rs @@ -525,6 +525,14 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input _ => {} } } + Modal::Onboarding => match key.code { + KeyCode::Enter => { + ctx.dispatch(AppCommand::DismissOnboarding); + ctx.dispatch(AppCommand::CloseModal); + ctx.app.save_settings(ctx.link); + } + _ => ctx.dispatch(AppCommand::CloseModal), + }, Modal::None => unreachable!(), } InputResult::Continue diff --git a/src/input/options_page.rs b/src/input/options_page.rs index c9b5074..ce2dd6a 100644 --- a/src/input/options_page.rs +++ b/src/input/options_page.rs @@ -87,6 +87,9 @@ pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) { } } } + OptionsFocus::ResetOnboarding => { + ctx.dispatch(AppCommand::ResetOnboarding); + } OptionsFocus::MidiInput0 | OptionsFocus::MidiInput1 | OptionsFocus::MidiInput2 @@ -162,6 +165,8 @@ pub(super) fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> Inpu ctx.playing .store(ctx.app.playback.playing, Ordering::Relaxed); } + KeyCode::Char('s') => super::open_save(ctx), + KeyCode::Char('l') => super::open_load(ctx), KeyCode::Char('?') => { ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); } diff --git a/src/input/patterns_page.rs b/src/input/patterns_page.rs index f918c6c..ab87e10 100644 --- a/src/input/patterns_page.rs +++ b/src/input/patterns_page.rs @@ -249,6 +249,8 @@ pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> Inp ctx.dispatch(AppCommand::ClearSolos); 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('?') => { ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); } diff --git a/src/model/docs.rs b/src/model/docs.rs index 0a34b90..792f549 100644 --- a/src/model/docs.rs +++ b/src/model/docs.rs @@ -27,6 +27,7 @@ pub const DOCS: &[DocEntry] = &[ Topic("The Dictionary", include_str!("../../docs/dictionary.md")), Topic("The Stack", include_str!("../../docs/stack.md")), Topic("Creating Words", include_str!("../../docs/definitions.md")), + Topic("Control Flow", include_str!("../../docs/control_flow.md")), Topic("The Prelude", include_str!("../../docs/prelude.md")), Topic("Oddities", include_str!("../../docs/oddities.md")), // Audio Engine @@ -68,6 +69,7 @@ pub const DOCS: &[DocEntry] = &[ 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 { diff --git a/src/page.rs b/src/page.rs index c0ec31f..344bf8c 100644 --- a/src/page.rs +++ b/src/page.rs @@ -91,4 +91,72 @@ impl Page { *self = page; } } + + pub const fn onboarding(self) -> (&'static str, &'static [(&'static str, &'static str)]) { + match self { + Page::Main => ( + "The step sequencer. Each cell holds a Forth script. When playing, active steps are evaluated in order to produce sound. The grid shows step numbers, names, link indicators, and highlights the currently playing step.", + &[ + ("Arrows", "navigate"), + ("Enter", "edit script"), + ("Space", "play/stop"), + ("t", "toggle step"), + ("p", "preview"), + ("Tab", "samples"), + ("?", "all keys"), + ], + ), + Page::Patterns => ( + "Organize your project into banks and patterns. The left column lists 32 banks, the right shows patterns in the selected bank. Stage patterns to play or stop, mute them, solo them, change their settings. Stage / commit system to apply all changes at once.", + &[ + ("Arrows", "navigate"), + ("Enter", "open in sequencer"), + ("Space", "stage play/stop"), + ("c", "commit changes"), + ("r", "rename"), + ("e", "properties"), + ("?", "all keys"), + ], + ), + Page::Engine => ( + "Audio engine configuration. Select output and input devices, adjust buffer size and polyphony, and manage sample directories. The right side shows a live scope and spectrum analyzer.", + &[ + ("Tab", "switch section"), + ("↑↓", "navigate"), + ("←→", "adjust"), + ("R", "restart engine"), + ("A", "add samples"), + ("?", "all keys"), + ], + ), + Page::Options => ( + "Global settings for display, UI, Link sync, MIDI, etc. All changes save automatically. Tutorial can be reset from here!", + &[ + ("↑↓", "navigate"), + ("←→", "change value"), + ("?", "all keys"), + ], + ), + Page::Help => ( + "Interactive documentation with executable Forth examples. Browse topics on the left, read content on the right. Code blocks can be run directly and evaluated by the sequencer engine.", + &[ + ("Tab", "switch panels"), + ("↑↓", "navigate"), + ("Enter", "run code block"), + ("n/p", "next/prev example"), + ("/", "search"), + ("?", "all keys"), + ], + ), + Page::Dict => ( + "Complete reference of all Forth words by category. Each entry shows the word name, stack effect signature, description, and a usage example. Search filters across all categories.", + &[ + ("Tab", "switch panels"), + ("↑↓", "navigate"), + ("/", "search"), + ("?", "all keys"), + ], + ), + } + } } diff --git a/src/services/help_nav.rs b/src/services/help_nav.rs index 8549684..2ad8379 100644 --- a/src/services/help_nav.rs +++ b/src/services/help_nav.rs @@ -17,11 +17,13 @@ pub fn select_topic(ui: &mut UiState, index: usize) { pub fn next_topic(ui: &mut UiState, n: usize) { let count = docs::topic_count(); ui.help_topic = (ui.help_topic + n) % count; + ui.help_focused_block = None; } pub fn prev_topic(ui: &mut UiState, n: usize) { let count = docs::topic_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) { diff --git a/src/settings.rs b/src/settings.rs index e690e07..928af33 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -45,18 +45,26 @@ pub struct DisplaySettings { pub show_completion: bool, #[serde(default = "default_font")] pub font: String, + #[serde(default = "default_zoom")] + pub zoom_factor: f32, #[serde(default)] pub color_scheme: ColorScheme, #[serde(default)] pub layout: MainLayout, #[serde(default)] pub hue_rotation: f32, + #[serde(default)] + pub onboarding_dismissed: Vec, } fn default_font() -> String { "8x13".to_string() } +fn default_zoom() -> f32 { + 1.5 +} + #[derive(Debug, Serialize, Deserialize)] pub struct LinkSettings { pub enabled: bool, @@ -88,9 +96,11 @@ impl Default for DisplaySettings { show_preview: true, show_completion: true, font: default_font(), + zoom_factor: default_zoom(), color_scheme: ColorScheme::default(), layout: MainLayout::default(), hue_rotation: 0.0, + onboarding_dismissed: Vec::new(), } } } diff --git a/src/state/modal.rs b/src/state/modal.rs index bbc9f83..a96a3ac 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -90,4 +90,5 @@ pub enum Modal { steps: String, rotation: String, }, + Onboarding, } diff --git a/src/state/options.rs b/src/state/options.rs index 9e95e7d..680f0fa 100644 --- a/src/state/options.rs +++ b/src/state/options.rs @@ -22,6 +22,7 @@ pub enum OptionsFocus { MidiInput1, MidiInput2, MidiInput3, + ResetOnboarding, } impl CyclicEnum for OptionsFocus { @@ -45,6 +46,7 @@ impl CyclicEnum for OptionsFocus { Self::MidiInput1, Self::MidiInput2, Self::MidiInput3, + Self::ResetOnboarding, ]; } @@ -68,6 +70,7 @@ const FOCUS_LINES: &[(OptionsFocus, usize)] = &[ (OptionsFocus::MidiInput1, 33), (OptionsFocus::MidiInput2, 34), (OptionsFocus::MidiInput3, 35), + (OptionsFocus::ResetOnboarding, 39), ]; impl OptionsFocus { diff --git a/src/state/ui.rs b/src/state/ui.rs index 914885c..83662cc 100644 --- a/src/state/ui.rs +++ b/src/state/ui.rs @@ -1,6 +1,7 @@ use std::cell::RefCell; use std::time::{Duration, Instant}; +use cagire_markdown::ParsedMarkdown; use cagire_ratatui::Sparkles; use tachyonfx::{fx, Effect, EffectManager, Interpolation}; @@ -49,6 +50,8 @@ pub struct UiState { pub help_scrolls: Vec, pub help_search_active: bool, pub help_search_query: String, + pub help_focused_block: Option, + pub help_parsed: RefCell>>, pub dict_focus: DictFocus, pub dict_category: usize, pub dict_scrolls: Vec, @@ -66,6 +69,7 @@ pub struct UiState { pub prev_modal_open: bool, pub prev_page: Page, pub prev_show_title: bool, + pub onboarding_dismissed: Vec, } impl Default for UiState { @@ -81,6 +85,8 @@ impl Default for UiState { help_scrolls: vec![0; crate::model::docs::topic_count()], help_search_active: false, 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_category: 0, dict_scrolls: vec![0; crate::model::categories::category_count()], @@ -98,6 +104,7 @@ impl Default for UiState { prev_modal_open: false, prev_page: Page::default(), prev_show_title: true, + onboarding_dismissed: Vec::new(), } } } diff --git a/src/views/help_view.rs b/src/views/help_view.rs index 15a8a69..8a329ce 100644 --- a/src/views/help_view.rs +++ b/src/views/help_view.rs @@ -183,7 +183,15 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { let has_query = !query.is_empty(); 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 content_area = if has_search_bar { @@ -201,13 +209,33 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { let visible_height = content_area.height.saturating_sub(6) as usize; // Calculate total wrapped line count for accurate max_scroll - let total_wrapped: usize = lines + let total_wrapped: usize = parsed + .lines .iter() .map(|l| wrapped_line_count(l, content_width)) .sum(); let max_scroll = total_wrapped.saturating_sub(visible_height); let scroll = app.ui.help_scroll().min(max_scroll); + let 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 = if has_query { lines .into_iter() diff --git a/src/views/highlight.rs b/src/views/highlight.rs index b89c044..0f86110 100644 --- a/src/views/highlight.rs +++ b/src/views/highlight.rs @@ -191,7 +191,7 @@ fn classify_word(word: &str, user_words: &HashSet) -> (TokenKind, bool) 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); } diff --git a/src/views/keybindings.rs b/src/views/keybindings.rs index eb5e1e9..ab36d74 100644 --- a/src/views/keybindings.rs +++ b/src/views/keybindings.rs @@ -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"), ("Ctrl+←→↑↓", "Navigate", "Switch between adjacent views"), ("q", "Quit", "Quit application"), + ("s", "Save", "Save project"), + ("l", "Load", "Load project"), ("?", "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(("L", "Set length", "Open length 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(("r", "Rename", "Rename current step")); 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(("Shift+Tab", "Topic", "Previous topic")); 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(("Esc", "Clear", "Clear search")); + bindings.push(("Esc", "Clear", "Clear search / deselect block")); } Page::Dict => { bindings.push(("Tab", "Focus", "Toggle category/words focus")); diff --git a/src/views/options_view.rs b/src/views/options_view.rs index 5ff23fe..36c1a97 100644 --- a/src/views/options_view.rs +++ b/src/views/options_view.rs @@ -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_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 lines: Vec = 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 2", &midi_in_2, focus == OptionsFocus::MidiInput2, &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(); diff --git a/src/views/render.rs b/src/views/render.rs index 7960bd0..7323a85 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -4,7 +4,7 @@ use std::time::Duration; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; 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 crate::app::App; @@ -418,13 +418,20 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { ("Space", "Play"), ("?", "Keys"), ], - Page::Help => vec![ - ("↑↓", "Scroll"), - ("Tab", "Topic"), - ("PgUp/Dn", "Page"), - ("/", "Search"), - ("?", "Keys"), - ], + Page::Help => match app.ui.help_focus { + crate::state::HelpFocus::Content => vec![ + ("n", "Next Example"), + ("p", "Previous Example"), + ("Enter", "Evaluate"), + ("Tab", "Topics"), + ], + crate::state::HelpFocus::Topics => vec![ + ("↑↓", "Navigate"), + ("Tab", "Content"), + ("/", "Search"), + ("?", "Keys"), + ], + }, Page::Dict => vec![ ("Tab", "Focus"), ("↑↓", "Navigate"), @@ -594,6 +601,60 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term inner } + Modal::Onboarding => { + let (desc, keys) = app.page.onboarding(); + let text_width = 51usize; // inner width minus 2 for padding + let desc_lines = { + let mut lines = 0u16; + for line in desc.split('\n') { + lines += (line.len() as u16).max(1).div_ceil(text_width as u16); + } + lines + }; + let key_lines = keys.len() as u16; + let modal_height = (3 + desc_lines + 1 + key_lines + 2).min(term.height.saturating_sub(4)); // border + pad + desc + gap + keys + pad + hint + + let inner = ModalFrame::new(&format!(" {} ", app.page.name())) + .width(57) + .height(modal_height) + .border_color(theme.modal.confirm) + .render_centered(frame, term); + + let content_width = inner.width.saturating_sub(4); + let mut y = inner.y + 1; + + let desc_area = Rect::new(inner.x + 2, y, content_width, desc_lines); + let body = Paragraph::new(desc) + .style(Style::new().fg(theme.ui.text_primary)) + .wrap(Wrap { trim: true }); + frame.render_widget(body, desc_area); + y += desc_lines + 1; + + for &(key, action) in keys { + if y >= inner.y + inner.height - 1 { + break; + } + let line = Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("{:>8}", key), + Style::new().fg(theme.hint.key), + ), + Span::styled( + format!(" {action}"), + Style::new().fg(theme.hint.text), + ), + ]); + frame.render_widget(Paragraph::new(line), Rect::new(inner.x + 1, y, inner.width.saturating_sub(2), 1)); + y += 1; + } + + let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); + let hints = hint_line(&[("Enter", "don't show again"), ("any key", "dismiss")]); + frame.render_widget(Paragraph::new(hints).alignment(Alignment::Center), hint_area); + + inner + } Modal::KeybindingsHelp { scroll } => render_modal_keybindings(frame, app, *scroll, term), Modal::EuclideanDistribution { source_step, diff --git a/src/views/title_view.rs b/src/views/title_view.rs index a1f11d3..1a46772 100644 --- a/src/views/title_view.rs +++ b/src/views/title_view.rs @@ -52,9 +52,32 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) { Line::from(Span::styled("AGPL-3.0", license_style)), Line::from(""), Line::from(""), + Line::from(vec![ + Span::styled("Ctrl+Arrows", Style::new().fg(theme.title.link)), + Span::styled(": navigate views", Style::new().fg(theme.title.prompt)), + ]), + Line::from(vec![ + Span::styled("Enter", Style::new().fg(theme.title.link)), + Span::styled(": edit step ", Style::new().fg(theme.title.prompt)), + Span::styled("Space", Style::new().fg(theme.title.link)), + Span::styled(": play/stop", Style::new().fg(theme.title.prompt)), + ]), + Line::from(vec![ + Span::styled("s", Style::new().fg(theme.title.link)), + Span::styled(": save ", Style::new().fg(theme.title.prompt)), + Span::styled("l", Style::new().fg(theme.title.link)), + Span::styled(": load ", Style::new().fg(theme.title.prompt)), + Span::styled("q", Style::new().fg(theme.title.link)), + Span::styled(": quit", Style::new().fg(theme.title.prompt)), + ]), + Line::from(vec![ + Span::styled("?", Style::new().fg(theme.title.link)), + Span::styled(": keybindings", Style::new().fg(theme.title.prompt)), + ]), + Line::from(""), Line::from(Span::styled( "Press any key to continue", - Style::new().fg(theme.title.prompt), + Style::new().fg(theme.title.subtitle), )), ]; diff --git a/tests/forth.rs b/tests/forth.rs index 1d5e7cd..b78926c 100644 --- a/tests/forth.rs +++ b/tests/forth.rs @@ -60,3 +60,6 @@ mod chords; #[path = "forth/euclidean.rs"] mod euclidean; + +#[path = "forth/case_statement.rs"] +mod case_statement; diff --git a/tests/forth/case_statement.rs b/tests/forth/case_statement.rs new file mode 100644 index 0000000..3b63d77 --- /dev/null +++ b/tests/forth/case_statement.rs @@ -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'"); +} diff --git a/tests/forth/variables.rs b/tests/forth/variables.rs index fbcd70e..97c7a50 100644 --- a/tests/forth/variables.rs +++ b/tests/forth/variables.rs @@ -42,3 +42,28 @@ fn float_var() { fn increment_pattern() { 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); +}