diff --git a/src/input.rs b/src/input.rs index afb2a90..3bb5656 100644 --- a/src/input.rs +++ b/src/input.rs @@ -572,6 +572,25 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { _ => {} } } + Modal::KeybindingsHelp { scroll } => { + let bindings_count = crate::views::keybindings::bindings_for(ctx.app.page).len(); + match key.code { + KeyCode::Esc | KeyCode::Char('?') => ctx.dispatch(AppCommand::CloseModal), + KeyCode::Up | KeyCode::Char('k') => { + *scroll = scroll.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + *scroll = (*scroll + 1).min(bindings_count.saturating_sub(1)); + } + KeyCode::PageUp => { + *scroll = scroll.saturating_sub(10); + } + KeyCode::PageDown => { + *scroll = (*scroll + 10).min(bindings_count.saturating_sub(1)); + } + _ => {} + } + } Modal::None => unreachable!(), } InputResult::Continue @@ -845,6 +864,9 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR })); } } + KeyCode::Char('?') => { + ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); + } _ => {} } InputResult::Continue @@ -952,6 +974,9 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern }); } } + KeyCode::Char('?') => { + ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); + } _ => {} } InputResult::Continue @@ -1106,6 +1131,9 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { "/sound/sine/dur/0.5/decay/0.2".into(), )); } + KeyCode::Char('?') => { + ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); + } _ => {} } InputResult::Continue @@ -1151,6 +1179,9 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { ctx.playing .store(ctx.app.playback.playing, Ordering::Relaxed); } + KeyCode::Char('?') => { + ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); + } _ => {} } InputResult::Continue @@ -1184,6 +1215,9 @@ fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { selected: false, })); } + KeyCode::Char('?') => { + ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); + } _ => {} } InputResult::Continue @@ -1229,6 +1263,9 @@ fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { selected: false, })); } + KeyCode::Char('?') => { + ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); + } _ => {} } InputResult::Continue diff --git a/src/state/modal.rs b/src/state/modal.rs index 2414c31..e6c1f36 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -57,4 +57,7 @@ pub enum Modal { quantization: LaunchQuantization, sync_mode: SyncMode, }, + KeybindingsHelp { + scroll: usize, + }, } diff --git a/src/views/keybindings.rs b/src/views/keybindings.rs new file mode 100644 index 0000000..c515702 --- /dev/null +++ b/src/views/keybindings.rs @@ -0,0 +1,91 @@ +use crate::page::Page; + +pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str)> { + let mut bindings = Vec::new(); + + // Global bindings + bindings.push(("Ctrl+←→↑↓", "Navigate", "Switch between views")); + bindings.push(("q", "Quit", "Quit application")); + bindings.push(("?", "Keybindings", "Show this help")); + + // Page-specific bindings + match page { + Page::Main => { + bindings.push(("Space", "Play/Stop", "Toggle playback")); + bindings.push(("←→↑↓", "Navigate", "Move cursor between steps")); + bindings.push(("Shift+←→↑↓", "Select", "Extend selection")); + bindings.push(("Esc", "Clear", "Clear selection")); + bindings.push(("Enter", "Edit", "Open step editor")); + bindings.push(("t", "Toggle", "Toggle selected steps")); + bindings.push(("p", "Preview", "Preview step script")); + bindings.push(("Tab", "Samples", "Toggle sample browser")); + bindings.push(("Ctrl+C", "Copy", "Copy selected steps")); + bindings.push(("Ctrl+V", "Paste", "Paste steps")); + bindings.push(("Ctrl+B", "Link", "Paste as linked steps")); + bindings.push(("Ctrl+D", "Duplicate", "Duplicate selection")); + bindings.push(("Ctrl+H", "Harden", "Convert links to copies")); + bindings.push(("Del", "Delete", "Delete step(s)")); + bindings.push(("< >", "Length", "Decrease/increase pattern length")); + bindings.push(("[ ]", "Speed", "Decrease/increase pattern speed")); + bindings.push(("+ -", "Tempo", "Increase/decrease tempo")); + 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)")); + } + Page::Patterns => { + bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns")); + bindings.push(("Enter", "Select", "Select pattern for editing")); + bindings.push(("Space", "Play", "Toggle pattern playback")); + bindings.push(("Esc", "Back", "Clear staged or go back")); + bindings.push(("c", "Commit", "Commit staged changes")); + bindings.push(("r", "Rename", "Rename bank/pattern")); + bindings.push(("e", "Properties", "Edit pattern properties")); + bindings.push(("Ctrl+C", "Copy", "Copy bank/pattern")); + bindings.push(("Ctrl+V", "Paste", "Paste bank/pattern")); + bindings.push(("Del", "Reset", "Reset bank/pattern")); + } + Page::Engine => { + bindings.push(("Tab", "Section", "Next section")); + bindings.push(("Shift+Tab", "Section", "Previous section")); + bindings.push(("←→", "Switch", "Switch device type or adjust setting")); + bindings.push(("↑↓", "Navigate", "Navigate list items")); + bindings.push(("PgUp/Dn", "Page", "Page through device list")); + bindings.push(("Enter", "Select", "Select device")); + bindings.push(("R", "Restart", "Restart audio engine")); + bindings.push(("A", "Add path", "Add sample path")); + bindings.push(("D", "Refresh/Del", "Refresh devices or delete path")); + bindings.push(("h", "Hush", "Stop all sounds gracefully")); + bindings.push(("p", "Panic", "Stop all sounds immediately")); + bindings.push(("r", "Reset", "Reset peak voice counter")); + bindings.push(("t", "Test", "Play test tone")); + } + Page::Options => { + bindings.push(("Tab", "Next", "Move to next option")); + bindings.push(("Shift+Tab", "Previous", "Move to previous option")); + bindings.push(("↑↓", "Navigate", "Navigate options")); + bindings.push(("←→", "Toggle", "Toggle or adjust option")); + bindings.push(("Space", "Play/Stop", "Toggle playback")); + } + Page::Help => { + bindings.push(("↑↓ j/k", "Scroll", "Scroll content")); + bindings.push(("Tab", "Topic", "Next topic")); + bindings.push(("Shift+Tab", "Topic", "Previous topic")); + bindings.push(("PgUp/Dn", "Page", "Page scroll")); + bindings.push(("/", "Search", "Activate search")); + bindings.push(("Esc", "Clear", "Clear search")); + } + Page::Dict => { + bindings.push(("Tab", "Focus", "Toggle category/words focus")); + bindings.push(("↑↓ j/k", "Navigate", "Navigate items")); + bindings.push(("PgUp/Dn", "Page", "Page scroll")); + bindings.push(("/", "Search", "Activate search")); + bindings.push(("Ctrl+F", "Search", "Activate search")); + bindings.push(("Esc", "Clear", "Clear search")); + } + } + + bindings +} diff --git a/src/views/mod.rs b/src/views/mod.rs index 3756da6..37f31d1 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -2,6 +2,7 @@ pub mod dict_view; pub mod engine_view; pub mod help_view; pub mod highlight; +pub mod keybindings; pub mod main_view; pub mod options_view; pub mod patterns_view; diff --git a/src/views/render.rs b/src/views/render.rs index 630da5c..0af749c 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -3,7 +3,7 @@ use std::time::Instant; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table}; use ratatui::Frame; use crate::app::App; @@ -304,11 +304,9 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { ("^V", "Paste"), ("^B", "Link"), ("^D", "Dup"), - ("^H", "Harden"), ("Del", "Delete"), - ("<>", "Len"), - ("[]", "Spd"), ("+-", "Tempo"), + ("?", "Keys"), ], Page::Patterns => vec![ ("←→↑↓", "Navigate"), @@ -317,6 +315,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { ("Esc", "Back"), ("r", "Rename"), ("Del", "Reset"), + ("?", "Keys"), ], Page::Engine => vec![ ("Tab", "Section"), @@ -324,15 +323,27 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { ("↑↓", "Navigate"), ("Enter", "Select"), ("A", "Add path"), + ("?", "Keys"), + ], + Page::Options => vec![ + ("Tab", "Next"), + ("←→", "Toggle"), + ("Space", "Play"), + ("?", "Keys"), ], - Page::Options => vec![("Tab", "Next"), ("←→", "Toggle"), ("Space", "Play")], Page::Help => vec![ ("↑↓", "Scroll"), ("Tab", "Topic"), ("PgUp/Dn", "Page"), ("/", "Search"), + ("?", "Keys"), + ], + Page::Dict => vec![ + ("Tab", "Focus"), + ("↑↓", "Navigate"), + ("/", "Search"), + ("?", "Keys"), ], - Page::Dict => vec![("Tab", "Focus"), ("↑↓", "Navigate"), ("/", "Search")], }; let page_width = page_indicator.chars().count(); @@ -772,5 +783,73 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term ]); frame.render_widget(Paragraph::new(hint), hint_area); } + Modal::KeybindingsHelp { scroll } => { + let width = (term.width * 80 / 100).clamp(60, 100); + let height = (term.height * 80 / 100).max(15); + + let title = format!("Keybindings — {}", app.page.name()); + let inner = ModalFrame::new(&title) + .width(width) + .height(height) + .border_color(Color::Rgb(100, 160, 180)) + .render_centered(frame, term); + + let bindings = super::keybindings::bindings_for(app.page); + let visible_rows = inner.height.saturating_sub(2) as usize; + + let rows: Vec = bindings + .iter() + .enumerate() + .skip(*scroll) + .take(visible_rows) + .map(|(i, (key, name, desc))| { + let bg = if i % 2 == 0 { + Color::Rgb(25, 25, 30) + } else { + Color::Rgb(35, 35, 42) + }; + Row::new(vec![ + Cell::from(*key).style(Style::default().fg(Color::Yellow)), + Cell::from(*name).style(Style::default().fg(Color::Cyan)), + Cell::from(*desc).style(Style::default().fg(Color::White)), + ]) + .style(Style::default().bg(bg)) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(14), + Constraint::Length(12), + Constraint::Fill(1), + ], + ) + .column_spacing(2); + + let table_area = Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: inner.height.saturating_sub(1), + }; + frame.render_widget(table, table_area); + + let hint_area = Rect { + x: inner.x, + y: inner.y + inner.height.saturating_sub(1), + width: inner.width, + height: 1, + }; + let hint = Line::from(vec![ + Span::styled("↑↓", Style::default().fg(Color::Yellow)), + Span::styled(" scroll ", Style::default().fg(Color::DarkGray)), + Span::styled("PgUp/Dn", Style::default().fg(Color::Yellow)), + Span::styled(" page ", Style::default().fg(Color::DarkGray)), + Span::styled("Esc/?", Style::default().fg(Color::Yellow)), + Span::styled(" close", Style::default().fg(Color::DarkGray)), + ]); + frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area); + } } }