diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index ce0d8b4..e465fb0 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -112,7 +112,7 @@ impl Forth { let vars_snapshot = self.vars.load_full(); let mut var_writes: HashMap = HashMap::new(); - cmd.set_global(self.global_params.lock().clone()); + cmd.set_global(std::mem::take(&mut *self.global_params.lock())); self.execute_ops( ops, @@ -459,7 +459,7 @@ impl Forth { if b.as_float().map_or(true, |v| v == 0.0) { return Err("division by zero".into()); } - stack.push(lift_binary(a, b, |x, y| x / y)?); + stack.push(lift_binary(&a, &b, |x, y| x / y)?); } Op::Mod => { let b = pop(stack)?; @@ -467,47 +467,47 @@ impl Forth { if b.as_float().map_or(true, |v| v == 0.0) { return Err("modulo by zero".into()); } - let result = lift_binary(a, b, |x, y| (x as i64 % y as i64) as f64)?; + let result = lift_binary(&a, &b, |x, y| (x as i64 % y as i64) as f64)?; stack.push(result); } Op::Neg => { let v = pop(stack)?; - stack.push(lift_unary(v, |x| -x)?); + stack.push(lift_unary(&v, |x| -x)?); } Op::Abs => { let v = pop(stack)?; - stack.push(lift_unary(v, |x| x.abs())?); + stack.push(lift_unary(&v, |x| x.abs())?); } Op::Floor => { let v = pop(stack)?; - stack.push(lift_unary(v, |x| x.floor())?); + stack.push(lift_unary(&v, |x| x.floor())?); } Op::Ceil => { let v = pop(stack)?; - stack.push(lift_unary(v, |x| x.ceil())?); + stack.push(lift_unary(&v, |x| x.ceil())?); } Op::Round => { let v = pop(stack)?; - stack.push(lift_unary(v, |x| x.round())?); + stack.push(lift_unary(&v, |x| x.round())?); } Op::Min => binary_op(stack, |a, b| a.min(b))?, Op::Max => binary_op(stack, |a, b| a.max(b))?, Op::Pow => binary_op(stack, |a, b| a.powf(b))?, Op::Sqrt => { let v = pop(stack)?; - stack.push(lift_unary(v, |x| x.sqrt())?); + stack.push(lift_unary(&v, |x| x.sqrt())?); } Op::Sin => { let v = pop(stack)?; - stack.push(lift_unary(v, |x| x.sin())?); + stack.push(lift_unary(&v, |x| x.sin())?); } Op::Cos => { let v = pop(stack)?; - stack.push(lift_unary(v, |x| x.cos())?); + stack.push(lift_unary(&v, |x| x.cos())?); } Op::Log => { let v = pop(stack)?; - stack.push(lift_unary(v, |x| x.ln())?); + stack.push(lift_unary(&v, |x| x.ln())?); } Op::Eq => cmp_op(stack, |a, b| (a - b).abs() < f64::EPSILON)?, @@ -1055,7 +1055,7 @@ impl Forth { let key = read_key(&var_writes_cell, vars_snapshot); let values = std::mem::take(stack); for val in values { - let result = lift_unary_int(val, |degree| { + let result = lift_unary_int(&val, |degree| { let octave_offset = degree.div_euclid(len); let idx = degree.rem_euclid(len) as usize; key + octave_offset * 12 + pattern[idx] @@ -1155,7 +1155,7 @@ impl Forth { Op::Oct => { let shift = pop(stack)?; let note = pop(stack)?; - let result = lift_binary(note, shift, |n, s| n + s * 12.0)?; + let result = lift_binary(¬e, &shift, |n, s| n + s * 12.0)?; stack.push(result); } @@ -1921,65 +1921,65 @@ fn float_to_value(result: f64) -> Value { } } -fn lift_unary(val: Value, f: F) -> Result +fn lift_unary(val: &Value, f: F) -> Result where F: Fn(f64) -> f64 + Copy, { match val { Value::ArpList(items) => { - let mapped: Result, _> = items.iter().map(|x| lift_unary(x.clone(), f)).collect(); + let mapped: Result, _> = items.iter().map(|x| lift_unary(x, f)).collect(); Ok(Value::ArpList(Arc::from(mapped?))) } Value::CycleList(items) => { - let mapped: Result, _> = items.iter().map(|x| lift_unary(x.clone(), f)).collect(); + let mapped: Result, _> = items.iter().map(|x| lift_unary(x, f)).collect(); Ok(Value::CycleList(Arc::from(mapped?))) } v => Ok(float_to_value(f(v.as_float()?))), } } -fn lift_unary_int(val: Value, f: F) -> Result +fn lift_unary_int(val: &Value, f: F) -> Result where F: Fn(i64) -> i64 + Copy, { match val { Value::ArpList(items) => { let mapped: Result, _> = - items.iter().map(|x| lift_unary_int(x.clone(), f)).collect(); + items.iter().map(|x| lift_unary_int(x, f)).collect(); Ok(Value::ArpList(Arc::from(mapped?))) } Value::CycleList(items) => { let mapped: Result, _> = - items.iter().map(|x| lift_unary_int(x.clone(), f)).collect(); + items.iter().map(|x| lift_unary_int(x, f)).collect(); Ok(Value::CycleList(Arc::from(mapped?))) } v => Ok(Value::Int(f(v.as_int()?), None)), } } -fn lift_binary(a: Value, b: Value, f: F) -> Result +fn lift_binary(a: &Value, b: &Value, f: F) -> Result where F: Fn(f64, f64) -> f64 + Copy, { match (a, b) { (Value::ArpList(items), b) => { let mapped: Result, _> = - items.iter().map(|x| lift_binary(x.clone(), b.clone(), f)).collect(); + items.iter().map(|x| lift_binary(x, b, f)).collect(); Ok(Value::ArpList(Arc::from(mapped?))) } (a, Value::ArpList(items)) => { let mapped: Result, _> = - items.iter().map(|x| lift_binary(a.clone(), x.clone(), f)).collect(); + items.iter().map(|x| lift_binary(a, x, f)).collect(); Ok(Value::ArpList(Arc::from(mapped?))) } (Value::CycleList(items), b) => { let mapped: Result, _> = - items.iter().map(|x| lift_binary(x.clone(), b.clone(), f)).collect(); + items.iter().map(|x| lift_binary(x, b, f)).collect(); Ok(Value::CycleList(Arc::from(mapped?))) } (a, Value::CycleList(items)) => { let mapped: Result, _> = - items.iter().map(|x| lift_binary(a.clone(), x.clone(), f)).collect(); + items.iter().map(|x| lift_binary(a, x, f)).collect(); Ok(Value::CycleList(Arc::from(mapped?))) } (a, b) => Ok(float_to_value(f(a.as_float()?, b.as_float()?))), @@ -1992,7 +1992,7 @@ where { let b = pop(stack)?; let a = pop(stack)?; - stack.push(lift_binary(a, b, f)?); + stack.push(lift_binary(&a, &b, f)?); Ok(()) } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 06a84c3..1196756 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -170,6 +170,17 @@ impl LaunchQuantization { } } + pub fn short_label(&self) -> &'static str { + match self { + Self::Immediate => "Imm", + Self::Beat => "Bt", + Self::Bar => "1B", + Self::Bars2 => "2B", + Self::Bars4 => "4B", + Self::Bars8 => "8B", + } + } + /// Cycle to the next longer quantization, clamped at `Bars8`. pub fn next(&self) -> Self { match self { @@ -212,6 +223,13 @@ impl SyncMode { } } + pub fn short_label(&self) -> &'static str { + match self { + Self::Reset => "Rst", + Self::PhaseLock => "Plk", + } + } + /// Toggle between Reset and PhaseLock. pub fn toggle(&self) -> Self { match self { diff --git a/crates/ratatui/src/editor.rs b/crates/ratatui/src/editor.rs index 71780ad..2ec6a6d 100644 --- a/crates/ratatui/src/editor.rs +++ b/crates/ratatui/src/editor.rs @@ -1,6 +1,7 @@ //! Script editor widget with completion, search, and sample finder popups. use std::cell::Cell; +use std::sync::Arc; use crate::theme; use ratatui::{ @@ -25,7 +26,7 @@ pub struct CompletionCandidate { } struct CompletionState { - candidates: Vec, + candidates: Arc<[CompletionCandidate]>, matches: Vec, cursor: usize, prefix: String, @@ -37,7 +38,7 @@ struct CompletionState { impl CompletionState { fn new() -> Self { Self { - candidates: Vec::new(), + candidates: Arc::from([]), matches: Vec::new(), cursor: 0, prefix: String::new(), @@ -171,7 +172,7 @@ impl Editor { self.scroll_offset.set(0); } - pub fn set_candidates(&mut self, candidates: Vec) { + pub fn set_candidates(&mut self, candidates: Arc<[CompletionCandidate]>) { self.completion.candidates = candidates; } diff --git a/src/app/mod.rs b/src/app/mod.rs index 73f9de2..9f1a9e7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -29,7 +29,7 @@ use crate::state::{ ProjectState, ScriptEditorState, UiState, }; -static COMPLETION_CANDIDATES: LazyLock> = LazyLock::new(|| { +static COMPLETION_CANDIDATES: LazyLock> = LazyLock::new(|| { model::WORDS .iter() .map(|w| CompletionCandidate { diff --git a/src/app/scripting.rs b/src/app/scripting.rs index 76a85e3..f8f59d1 100644 --- a/src/app/scripting.rs +++ b/src/app/scripting.rs @@ -1,5 +1,7 @@ //! Forth script compilation, evaluation, and editor ↔ step synchronization. +use std::sync::Arc; + use crossbeam_channel::Sender; use crate::engine::LinkState; @@ -55,7 +57,7 @@ impl App { script.lines().map(String::from).collect() }; self.editor_ctx.editor.set_content(lines); - self.editor_ctx.editor.set_candidates(COMPLETION_CANDIDATES.clone()); + self.editor_ctx.editor.set_candidates(Arc::clone(&COMPLETION_CANDIDATES)); self.editor_ctx .editor .set_completion_enabled(self.ui.show_completion); @@ -87,7 +89,7 @@ impl App { prelude.lines().map(String::from).collect() }; self.editor_ctx.editor.set_content(lines); - self.editor_ctx.editor.set_candidates(COMPLETION_CANDIDATES.clone()); + self.editor_ctx.editor.set_candidates(Arc::clone(&COMPLETION_CANDIDATES)); self.editor_ctx .editor .set_completion_enabled(self.ui.show_completion); @@ -190,7 +192,7 @@ impl App { script.lines().map(String::from).collect() }; self.script_editor.editor.set_content(lines); - self.script_editor.editor.set_candidates(COMPLETION_CANDIDATES.clone()); + self.script_editor.editor.set_candidates(Arc::clone(&COMPLETION_CANDIDATES)); self.script_editor .editor .set_completion_enabled(self.ui.show_completion); diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index bebfe26..be4a12b 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -555,7 +555,7 @@ pub struct SequencerState { pattern_cache: PatternCache, pending_updates: HashMap<(usize, usize), PatternSnapshot>, runs_counter: RunsCounter, - step_traces: StepTracesMap, + step_traces: Arc, event_count: usize, script_engine: ScriptEngine, variables: Variables, @@ -593,7 +593,7 @@ impl SequencerState { pattern_cache: PatternCache::new(), pending_updates: HashMap::new(), runs_counter: RunsCounter::new(), - step_traces: HashMap::new(), + step_traces: Arc::new(HashMap::new()), event_count: 0, script_engine, variables, @@ -713,7 +713,7 @@ impl SequencerState { self.audio_state.active_patterns.clear(); self.audio_state.pending_starts.clear(); self.audio_state.pending_stops.clear(); - self.step_traces.clear(); + self.step_traces = Arc::new(HashMap::new()); self.runs_counter.counts.clear(); self.audio_state.flush_midi_notes = true; } @@ -731,7 +731,7 @@ impl SequencerState { self.speed_overrides.clear(); self.script_engine.clear_global_params(); self.runs_counter.counts.clear(); - self.step_traces.clear(); + self.step_traces = Arc::new(HashMap::new()); self.audio_state.flush_midi_notes = true; } SeqCommand::ResetScriptState => { @@ -811,7 +811,7 @@ impl SequencerState { fn tick_paused(&mut self) -> TickOutput { for pending in self.audio_state.pending_stops.drain(..) { self.audio_state.active_patterns.remove(&pending.id); - self.step_traces.retain(|&(bank, pattern, _), _| { + Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| { bank != pending.id.bank || pattern != pending.id.pattern }); let key = (pending.id.bank, pending.id.pattern); @@ -894,7 +894,7 @@ impl SequencerState { for pending in &self.audio_state.pending_stops { if check_quantization_boundary(pending.quantization, beat, prev_beat, quantum) { self.audio_state.active_patterns.remove(&pending.id); - self.step_traces.retain(|&(bank, pattern, _), _| { + Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| { bank != pending.id.bank || pattern != pending.id.pattern }); // Flush pending update so cache stays current for future launches @@ -1015,7 +1015,7 @@ impl SequencerState { .script_engine .evaluate_with_trace(script, &ctx, &mut trace) { - self.step_traces.insert( + Arc::make_mut(&mut self.step_traces).insert( (active.bank, active.pattern, source_idx), std::mem::take(&mut trace), ); @@ -1229,7 +1229,7 @@ impl SequencerState { last_step_beat: a.last_step_beat, }) .collect(), - step_traces: Arc::new(self.step_traces.clone()), + step_traces: Arc::clone(&self.step_traces), event_count: self.event_count, tempo: self.last_tempo, beat: self.last_beat, diff --git a/src/input/main_page.rs b/src/input/main_page.rs index 5d16b20..fea9eb1 100644 --- a/src/input/main_page.rs +++ b/src/input/main_page.rs @@ -79,9 +79,6 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool let current = format!("{:.1}", ctx.link.tempo()); ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current))); } - KeyCode::Char(':') => { - ctx.dispatch(AppCommand::OpenModal(Modal::JumpToStep(String::new()))); - } KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease), KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease), KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease), diff --git a/src/input/mod.rs b/src/input/mod.rs index 89030ba..bff1113 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -179,6 +179,19 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { return InputResult::Continue; } + if key.code == KeyCode::Char(':') { + let in_search = ctx.app.ui.dict_search_active || ctx.app.ui.help_search_active; + let in_script = ctx.app.page == Page::Script && ctx.app.script_editor.focused; + if !in_search && !in_script { + ctx.dispatch(AppCommand::OpenModal(Modal::CommandPalette { + input: String::new(), + cursor: 0, + scroll: 0, + })); + return InputResult::Continue; + } + } + match ctx.app.page { Page::Main => main_page::handle_main_page(ctx, key, ctrl), Page::Patterns => patterns_page::handle_patterns_page(ctx, key), diff --git a/src/input/modal.rs b/src/input/modal.rs index d45bf2f..5954d39 100644 --- a/src/input/modal.rs +++ b/src/input/modal.rs @@ -10,6 +10,49 @@ use crate::state::{ }; pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { + // Handle CommandPalette before the main match to avoid borrow conflicts + // (Enter needs &App for palette_entries while the match borrows &mut modal) + if let Modal::CommandPalette { input, cursor, scroll } = &mut ctx.app.ui.modal { + match key.code { + KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), + KeyCode::Char(c) => { + input.push(c); + *cursor = 0; + *scroll = 0; + } + KeyCode::Backspace => { + input.pop(); + *cursor = 0; + *scroll = 0; + } + KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => { + *cursor = cursor.saturating_sub(5); + } + KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => { + *cursor += 5; + } + KeyCode::Up => { + *cursor = cursor.saturating_sub(1); + } + KeyCode::Down => { + *cursor += 1; + } + KeyCode::PageUp => { + *cursor = cursor.saturating_sub(10); + } + KeyCode::PageDown => { + *cursor += 10; + } + KeyCode::Enter => { + let query = input.clone(); + let cursor_val = *cursor; + handle_palette_enter(ctx, &query, cursor_val); + } + _ => {} + } + return InputResult::Continue; + } + match &mut ctx.app.ui.modal { Modal::Confirm { action, selected } => { let confirmed = *selected; @@ -179,22 +222,6 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input KeyCode::Char(c) => input.push(c), _ => {} }, - Modal::JumpToStep(input) => match key.code { - KeyCode::Enter => { - if let Ok(step) = input.parse::() { - if step > 0 { - ctx.dispatch(AppCommand::GoToStep(step - 1)); - } - } - ctx.dispatch(AppCommand::CloseModal); - } - KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), - KeyCode::Backspace => { - input.pop(); - } - KeyCode::Char(c) if c.is_ascii_digit() => input.push(c), - _ => {} - }, Modal::SetTempo(input) => match key.code { KeyCode::Enter => { if let Ok(tempo) = input.parse::() { @@ -514,7 +541,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input } } Modal::KeybindingsHelp { scroll } => { - let bindings_count = crate::views::keybindings::bindings_for(ctx.app.page, ctx.app.plugin_mode).len(); + let bindings_count = crate::model::palette::bindings_for(ctx.app.page, ctx.app.plugin_mode).len(); match key.code { KeyCode::Esc | KeyCode::Char('?') => ctx.dispatch(AppCommand::CloseModal), KeyCode::Up | KeyCode::Char('k') => { @@ -636,6 +663,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input _ => ctx.dispatch(AppCommand::CloseModal), } } + Modal::CommandPalette { .. } => unreachable!(), Modal::None => unreachable!(), } InputResult::Continue @@ -670,6 +698,97 @@ fn execute_confirm(ctx: &mut InputContext, action: &ConfirmAction) -> InputResul InputResult::Continue } +fn handle_palette_enter(ctx: &mut InputContext, query: &str, cursor: usize) { + let page = ctx.app.page; + let plugin_mode = ctx.app.plugin_mode; + + // Numeric input on Main page → jump to step + if page == crate::page::Page::Main + && !query.is_empty() + && query.chars().all(|c| c.is_ascii_digit()) + { + if let Ok(step) = query.parse::() { + if step > 0 { + ctx.dispatch(AppCommand::GoToStep(step - 1)); + } + } + ctx.dispatch(AppCommand::CloseModal); + return; + } + + let entries = crate::model::palette::palette_entries(query, plugin_mode, ctx.app); + if let Some(entry) = entries.get(cursor) { + ctx.dispatch(AppCommand::CloseModal); + execute_palette_entry(ctx, entry); + } else { + ctx.dispatch(AppCommand::CloseModal); + } +} + +fn execute_palette_entry( + ctx: &mut InputContext, + entry: &crate::model::palette::CommandEntry, +) { + use crate::model::palette::PaletteAction; + use std::sync::atomic::Ordering; + + match &entry.action { + Some(PaletteAction::Resolve(f)) => { + if let Some(cmd) = f(ctx.app) { + ctx.dispatch(cmd); + } + } + Some(PaletteAction::Save) => super::open_save(ctx), + Some(PaletteAction::Load) => super::open_load(ctx), + Some(PaletteAction::TogglePlaying) => { + ctx.dispatch(AppCommand::TogglePlaying); + ctx.playing + .store(ctx.app.playback.playing, Ordering::Relaxed); + } + Some(PaletteAction::MuteToggle) => { + let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); + ctx.app.playback.toggle_mute(bank, pattern); + ctx.app.send_mute_state(ctx.seq_cmd_tx); + } + Some(PaletteAction::SoloToggle) => { + let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); + ctx.app.playback.toggle_solo(bank, pattern); + ctx.app.send_mute_state(ctx.seq_cmd_tx); + } + Some(PaletteAction::ClearMutes) => { + ctx.dispatch(AppCommand::ClearMutes); + ctx.app.send_mute_state(ctx.seq_cmd_tx); + } + Some(PaletteAction::ClearSolos) => { + ctx.dispatch(AppCommand::ClearSolos); + ctx.app.send_mute_state(ctx.seq_cmd_tx); + } + Some(PaletteAction::Hush) => { + let _ = ctx + .audio_tx + .load() + .send(crate::engine::AudioCommand::Hush); + } + Some(PaletteAction::Panic) => { + let _ = ctx + .audio_tx + .load() + .send(crate::engine::AudioCommand::Panic); + let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll); + } + Some(PaletteAction::TestTone) => { + let _ = ctx + .audio_tx + .load() + .send(crate::engine::AudioCommand::Evaluate { + cmd: "/sound/sine/dur/0.5/decay/0.2".into(), + time: None, + }); + } + None => {} + } +} + fn rename_command(target: &RenameTarget, name: Option) -> AppCommand { match target { RenameTarget::Bank { bank } => AppCommand::RenameBank { bank: *bank, name }, diff --git a/src/input/mouse.rs b/src/input/mouse.rs index cbd2f85..fc1f1ce 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -986,7 +986,8 @@ fn handle_modal_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) { Modal::FileBrowser(_) | Modal::AddSamplePath(_) => (60, 18), Modal::Rename { .. } => (40, 5), Modal::SetPattern { .. } | Modal::SetScript { .. } => (45, 5), - Modal::SetTempo(_) | Modal::JumpToStep(_) => (30, 5), + Modal::SetTempo(_) => (30, 5), + Modal::CommandPalette { .. } => (55, 20), _ => return, }; let modal_area = centered_rect(term, w, h); diff --git a/src/model/mod.rs b/src/model/mod.rs index 7e88138..8285b25 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -2,6 +2,7 @@ pub mod categories; pub mod demos; pub mod docs; pub mod onboarding; +pub mod palette; mod script; pub use cagire_forth::{ diff --git a/src/model/palette.rs b/src/model/palette.rs new file mode 100644 index 0000000..0d3e6b8 --- /dev/null +++ b/src/model/palette.rs @@ -0,0 +1,1320 @@ +use std::sync::LazyLock; + +use crate::app::App; +use crate::commands::AppCommand; +use crate::page::Page; +use crate::state::Modal; + +pub enum PaletteAction { + Resolve(fn(&App) -> Option), + Save, + Load, + TogglePlaying, + MuteToggle, + SoloToggle, + ClearMutes, + ClearSolos, + Hush, + Panic, + TestTone, +} + +pub struct CommandEntry { + pub name: &'static str, + pub description: &'static str, + pub category: &'static str, + pub keybinding: &'static str, + pub pages: &'static [Page], + pub normal_mode: bool, + pub action: Option, +} + +const CATEGORY_ORDER: &[&str] = &[ + "Project", + "Playback", + "Navigation", + "Editing", + "Pattern", + "Bank", + "Clipboard", + "Mute/Solo", + "Audio", + "Display", + "Prelude", + "Script", + "Help", +]; + +pub fn category_index(cat: &str) -> usize { + CATEGORY_ORDER.iter().position(|&c| c == cat).unwrap_or(usize::MAX) +} + +pub static COMMANDS: LazyLock> = LazyLock::new(|| { + use Page::*; + vec![ + // === Project === + CommandEntry { + name: "Save", + description: "Save project", + category: "Project", + keybinding: "s", + pages: &[], + normal_mode: false, + action: Some(PaletteAction::Save), + }, + CommandEntry { + name: "Load", + description: "Load project", + category: "Project", + keybinding: "l", + pages: &[], + normal_mode: false, + action: Some(PaletteAction::Load), + }, + CommandEntry { + name: "Quit", + description: "Quit application", + category: "Project", + keybinding: "q", + pages: &[], + normal_mode: true, + action: Some(PaletteAction::Resolve(|_| { + Some(AppCommand::OpenModal(Modal::Confirm { + action: crate::state::ConfirmAction::Quit, + selected: false, + })) + })), + }, + + // === Playback === + CommandEntry { + name: "Toggle Playback", + description: "Start or stop the sequencer", + category: "Playback", + keybinding: "Space", + pages: &[], + normal_mode: true, + action: Some(PaletteAction::TogglePlaying), + }, + CommandEntry { + name: "Tempo Up", + description: "Increase tempo", + category: "Playback", + keybinding: "+", + pages: &[Main], + normal_mode: true, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::TempoUp))), + }, + CommandEntry { + name: "Tempo Down", + description: "Decrease tempo", + category: "Playback", + keybinding: "-", + pages: &[Main], + normal_mode: true, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::TempoDown))), + }, + CommandEntry { + name: "Set Tempo", + description: "Open tempo input", + category: "Playback", + keybinding: "T", + pages: &[Main], + normal_mode: true, + action: Some(PaletteAction::Resolve(|_| { + Some(AppCommand::OpenModal(Modal::SetTempo(String::new()))) + })), + }, + + // === Navigation === + CommandEntry { + name: "Go to Dict", + description: "Open dictionary view", + category: "Navigation", + keybinding: "F1", + pages: &[], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::GoToPage(Dict)))), + }, + CommandEntry { + name: "Go to Patterns", + description: "Open patterns view", + category: "Navigation", + keybinding: "F2", + pages: &[], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::GoToPage(Patterns)))), + }, + CommandEntry { + name: "Go to Options", + description: "Open options view", + category: "Navigation", + keybinding: "F3", + pages: &[], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::GoToPage(Options)))), + }, + CommandEntry { + name: "Go to Help", + description: "Open help view", + category: "Navigation", + keybinding: "F4", + pages: &[], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::GoToPage(Help)))), + }, + CommandEntry { + name: "Go to Sequencer", + description: "Open sequencer view", + category: "Navigation", + keybinding: "F5", + pages: &[], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::GoToPage(Main)))), + }, + CommandEntry { + name: "Go to Engine", + description: "Open engine view", + category: "Navigation", + keybinding: "F6", + pages: &[], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::GoToPage(Engine)))), + }, + CommandEntry { + name: "Go to Script", + description: "Open script view", + category: "Navigation", + keybinding: "F11", + pages: &[], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::GoToPage(Script)))), + }, + + // === Editing === + CommandEntry { + name: "Toggle Steps", + description: "Toggle selected steps on/off", + category: "Editing", + keybinding: "t", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::ToggleSteps))), + }, + CommandEntry { + name: "Edit Step", + description: "Open step editor", + category: "Editing", + keybinding: "Enter", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::OpenModal(Modal::Editor)))), + }, + CommandEntry { + name: "Rename Step", + description: "Rename current step", + category: "Editing", + keybinding: "r", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|app| { + let (bank, pattern, step) = ( + app.editor_ctx.bank, + app.editor_ctx.pattern, + app.editor_ctx.step, + ); + let current_name = app + .current_edit_pattern() + .step(step) + .and_then(|s| s.name.clone()) + .unwrap_or_default(); + Some(AppCommand::OpenModal(Modal::Rename { + target: crate::state::RenameTarget::Step { bank, pattern, step }, + name: current_name, + })) + })), + }, + CommandEntry { + name: "Euclidean Distribution", + description: "Distribute linked steps using Euclidean rhythm", + category: "Editing", + keybinding: "e", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|app| { + let (bank, pattern, step) = ( + app.editor_ctx.bank, + app.editor_ctx.pattern, + app.editor_ctx.step, + ); + let pattern_len = app.current_edit_pattern().length; + let default_steps = pattern_len.min(32); + let default_pulses = (default_steps / 2).max(1).min(default_steps); + Some(AppCommand::OpenModal(Modal::EuclideanDistribution { + bank, + pattern, + source_step: step, + field: crate::state::EuclideanField::Pulses, + pulses: default_pulses.to_string(), + steps: default_steps.to_string(), + rotation: "0".to_string(), + })) + })), + }, + CommandEntry { + name: "Undo", + description: "Undo last action", + category: "Editing", + keybinding: "Ctrl+Z", + pages: &[Main, Patterns], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::Undo))), + }, + CommandEntry { + name: "Redo", + description: "Redo last action", + category: "Editing", + keybinding: "Ctrl+Shift+Z", + pages: &[Main, Patterns], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::Redo))), + }, + + // === Pattern === + CommandEntry { + name: "Length Increase", + description: "Increase pattern length", + category: "Pattern", + keybinding: ">", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::LengthIncrease))), + }, + CommandEntry { + name: "Length Decrease", + description: "Decrease pattern length", + category: "Pattern", + keybinding: "<", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::LengthDecrease))), + }, + CommandEntry { + name: "Speed Increase", + description: "Increase pattern speed", + category: "Pattern", + keybinding: "]", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::SpeedIncrease))), + }, + CommandEntry { + name: "Speed Decrease", + description: "Decrease pattern speed", + category: "Pattern", + keybinding: "[", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::SpeedDecrease))), + }, + CommandEntry { + name: "Set Length", + description: "Open length input", + category: "Pattern", + keybinding: "L", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| { + Some(AppCommand::OpenPatternModal(crate::state::PatternField::Length)) + })), + }, + CommandEntry { + name: "Set Speed", + description: "Open speed input", + category: "Pattern", + keybinding: "S", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| { + Some(AppCommand::OpenPatternModal(crate::state::PatternField::Speed)) + })), + }, + CommandEntry { + name: "Copy Pattern", + description: "Copy current pattern", + category: "Pattern", + keybinding: "Ctrl+C", + pages: &[Patterns], + normal_mode: false, + action: Some(PaletteAction::Resolve(|app| { + let (bank, pattern) = (app.editor_ctx.bank, app.editor_ctx.pattern); + Some(AppCommand::CopyPattern { bank, pattern }) + })), + }, + CommandEntry { + name: "Paste Pattern", + description: "Paste pattern from clipboard", + category: "Pattern", + keybinding: "Ctrl+V", + pages: &[Patterns], + normal_mode: false, + action: Some(PaletteAction::Resolve(|app| { + if app.copied_patterns.is_some() { + let (bank, pattern) = (app.editor_ctx.bank, app.editor_ctx.pattern); + Some(AppCommand::PastePattern { bank, pattern }) + } else { + None + } + })), + }, + CommandEntry { + name: "Reset Pattern", + description: "Reset current pattern", + category: "Pattern", + keybinding: "Del", + pages: &[Patterns], + normal_mode: false, + action: Some(PaletteAction::Resolve(|app| { + let (bank, pattern) = (app.editor_ctx.bank, app.editor_ctx.pattern); + Some(AppCommand::OpenModal(Modal::Confirm { + action: crate::state::ConfirmAction::ResetPattern { bank, pattern }, + selected: false, + })) + })), + }, + CommandEntry { + name: "Share Pattern", + description: "Export pattern to clipboard", + category: "Pattern", + keybinding: "g", + pages: &[Main, Patterns], + normal_mode: false, + action: Some(PaletteAction::Resolve(|app| { + let (bank, pattern) = (app.editor_ctx.bank, app.editor_ctx.pattern); + Some(AppCommand::SharePattern { bank, pattern }) + })), + }, + CommandEntry { + name: "Import Pattern", + description: "Import pattern from clipboard", + category: "Pattern", + keybinding: "G", + pages: &[Main, Patterns], + normal_mode: false, + action: Some(PaletteAction::Resolve(|app| { + let (bank, pattern) = (app.editor_ctx.bank, app.editor_ctx.pattern); + Some(AppCommand::ImportPattern { bank, pattern }) + })), + }, + CommandEntry { + name: "Pattern Properties", + description: "Edit pattern properties", + category: "Pattern", + keybinding: "e", + pages: &[Patterns], + normal_mode: false, + action: Some(PaletteAction::Resolve(|app| { + let (bank, pattern) = (app.editor_ctx.bank, app.editor_ctx.pattern); + Some(AppCommand::OpenPatternPropsModal { bank, pattern }) + })), + }, + CommandEntry { + name: "Describe Pattern", + description: "Add description to pattern", + category: "Pattern", + keybinding: "d", + pages: &[Patterns], + normal_mode: false, + action: Some(PaletteAction::Resolve(|app| { + let (bank, pattern) = (app.editor_ctx.bank, app.editor_ctx.pattern); + let current = app.project_state.project.banks[bank].patterns[pattern] + .description + .clone() + .unwrap_or_default(); + Some(AppCommand::OpenModal(Modal::Rename { + target: crate::state::RenameTarget::DescribePattern { bank, pattern }, + name: current, + })) + })), + }, + + // === Bank === + CommandEntry { + name: "Copy Bank", + description: "Copy current bank", + category: "Bank", + keybinding: "", + pages: &[Patterns], + normal_mode: false, + action: Some(PaletteAction::Resolve(|app| { + let bank = app.editor_ctx.bank; + Some(AppCommand::CopyBank { bank }) + })), + }, + CommandEntry { + name: "Paste Bank", + description: "Paste bank from clipboard", + category: "Bank", + keybinding: "", + pages: &[Patterns], + normal_mode: false, + action: Some(PaletteAction::Resolve(|app| { + if app.copied_banks.is_some() { + let bank = app.editor_ctx.bank; + Some(AppCommand::PasteBank { bank }) + } else { + None + } + })), + }, + CommandEntry { + name: "Reset Bank", + description: "Reset current bank", + category: "Bank", + keybinding: "", + pages: &[Patterns], + normal_mode: false, + action: Some(PaletteAction::Resolve(|app| { + let bank = app.editor_ctx.bank; + Some(AppCommand::OpenModal(Modal::Confirm { + action: crate::state::ConfirmAction::ResetBank { bank }, + selected: false, + })) + })), + }, + CommandEntry { + name: "Share Bank", + description: "Export bank to clipboard", + category: "Bank", + keybinding: "", + pages: &[Patterns], + normal_mode: false, + action: Some(PaletteAction::Resolve(|app| { + let bank = app.editor_ctx.bank; + Some(AppCommand::ShareBank { bank }) + })), + }, + CommandEntry { + name: "Import Bank", + description: "Import bank from clipboard", + category: "Bank", + keybinding: "", + pages: &[Patterns], + normal_mode: false, + action: Some(PaletteAction::Resolve(|app| { + let bank = app.editor_ctx.bank; + Some(AppCommand::OpenModal(Modal::Confirm { + action: crate::state::ConfirmAction::ImportBank { bank }, + selected: false, + })) + })), + }, + + // === Clipboard === + CommandEntry { + name: "Copy Steps", + description: "Copy selected steps", + category: "Clipboard", + keybinding: "Ctrl+C", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::CopySteps))), + }, + CommandEntry { + name: "Paste Steps", + description: "Paste steps", + category: "Clipboard", + keybinding: "Ctrl+V", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|app| { + if app.editor_ctx.copied_steps.is_some() { + Some(AppCommand::PasteSteps) + } else { + None + } + })), + }, + CommandEntry { + name: "Link Paste", + description: "Paste as linked steps", + category: "Clipboard", + keybinding: "Ctrl+B", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|app| { + if app.editor_ctx.copied_steps.is_some() { + Some(AppCommand::LinkPasteSteps) + } else { + None + } + })), + }, + CommandEntry { + name: "Duplicate", + description: "Duplicate selection", + category: "Clipboard", + keybinding: "Ctrl+D", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::DuplicateSteps))), + }, + CommandEntry { + name: "Harden", + description: "Convert links to copies", + category: "Clipboard", + keybinding: "Ctrl+H", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::HardenSteps))), + }, + + // === Mute/Solo === + CommandEntry { + name: "Mute", + description: "Arm mute for current pattern", + category: "Mute/Solo", + keybinding: "m", + pages: &[Main, Patterns], + normal_mode: false, + action: Some(PaletteAction::MuteToggle), + }, + CommandEntry { + name: "Solo", + description: "Arm solo for current pattern", + category: "Mute/Solo", + keybinding: "x", + pages: &[Main, Patterns], + normal_mode: false, + action: Some(PaletteAction::SoloToggle), + }, + CommandEntry { + name: "Clear Mutes", + description: "Clear all mutes", + category: "Mute/Solo", + keybinding: "M", + pages: &[Main, Patterns], + normal_mode: false, + action: Some(PaletteAction::ClearMutes), + }, + CommandEntry { + name: "Clear Solos", + description: "Clear all solos", + category: "Mute/Solo", + keybinding: "X", + pages: &[Main, Patterns], + normal_mode: false, + action: Some(PaletteAction::ClearSolos), + }, + + // === Audio === + CommandEntry { + name: "Restart Audio", + description: "Restart audio engine", + category: "Audio", + keybinding: "R", + pages: &[Engine], + normal_mode: true, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::AudioTriggerRestart))), + }, + CommandEntry { + name: "Hush", + description: "Stop all sounds gracefully", + category: "Audio", + keybinding: "h", + pages: &[Engine], + normal_mode: true, + action: Some(PaletteAction::Hush), + }, + CommandEntry { + name: "Panic", + description: "Stop all sounds immediately", + category: "Audio", + keybinding: "p", + pages: &[Engine], + normal_mode: true, + action: Some(PaletteAction::Panic), + }, + CommandEntry { + name: "Test Tone", + description: "Play test tone", + category: "Audio", + keybinding: "t", + pages: &[Engine], + normal_mode: true, + action: Some(PaletteAction::TestTone), + }, + CommandEntry { + name: "Refresh Devices", + description: "Refresh audio device list", + category: "Audio", + keybinding: "D", + pages: &[Engine], + normal_mode: true, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::AudioRefreshDevices))), + }, + CommandEntry { + name: "Reset Peak Voices", + description: "Reset peak voice counter", + category: "Audio", + keybinding: "r", + pages: &[Engine], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::ResetPeakVoices))), + }, + + // === Display === + CommandEntry { + name: "Toggle Scope", + description: "Show/hide oscilloscope", + category: "Display", + keybinding: "", + pages: &[Options], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::ToggleScope))), + }, + CommandEntry { + name: "Toggle Spectrum", + description: "Show/hide spectrum analyzer", + category: "Display", + keybinding: "", + pages: &[Options], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::ToggleSpectrum))), + }, + CommandEntry { + name: "Toggle Lissajous", + description: "Show/hide Lissajous display", + category: "Display", + keybinding: "", + pages: &[Options], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::ToggleLissajous))), + }, + CommandEntry { + name: "Toggle Preview", + description: "Show/hide step preview", + category: "Display", + keybinding: "", + pages: &[Options], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::TogglePreview))), + }, + CommandEntry { + name: "Toggle Runtime Highlight", + description: "Toggle runtime highlighting in editor", + category: "Display", + keybinding: "", + pages: &[Options], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::ToggleRuntimeHighlight))), + }, + CommandEntry { + name: "Toggle Completion", + description: "Toggle editor auto-completion", + category: "Display", + keybinding: "", + pages: &[Options], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::ToggleCompletion))), + }, + CommandEntry { + name: "Toggle Performance Mode", + description: "Toggle performance mode (minimal UI)", + category: "Display", + keybinding: "", + pages: &[Options], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::TogglePerformanceMode))), + }, + CommandEntry { + name: "Toggle Normalize Viz", + description: "Toggle visualization normalization", + category: "Display", + keybinding: "", + pages: &[Options], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::ToggleNormalizeViz))), + }, + CommandEntry { + name: "Cycle Scope Mode", + description: "Cycle oscilloscope display mode", + category: "Display", + keybinding: "", + pages: &[Options], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::CycleScopeMode))), + }, + CommandEntry { + name: "Flip Scope Orientation", + description: "Flip oscilloscope orientation", + category: "Display", + keybinding: "", + pages: &[Options], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::FlipScopeOrientation))), + }, + CommandEntry { + name: "Cycle Spectrum Mode", + description: "Cycle spectrum analyzer mode", + category: "Display", + keybinding: "", + pages: &[Options], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::CycleSpectrumMode))), + }, + + // === Prelude === + CommandEntry { + name: "Edit Prelude", + description: "Open prelude editor", + category: "Prelude", + keybinding: "p", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::OpenPreludeEditor))), + }, + CommandEntry { + name: "Evaluate Prelude", + description: "Re-evaluate prelude script", + category: "Prelude", + keybinding: "d", + pages: &[Main], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::EvaluatePrelude))), + }, + + // === Script === + CommandEntry { + name: "Set Script Length", + description: "Set script length via text input", + category: "Script", + keybinding: "L", + pages: &[Script], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| { + Some(AppCommand::OpenScriptModal(crate::state::ScriptField::Length)) + })), + }, + CommandEntry { + name: "Set Script Speed", + description: "Set script speed via text input", + category: "Script", + keybinding: "S", + pages: &[Script], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| { + Some(AppCommand::OpenScriptModal(crate::state::ScriptField::Speed)) + })), + }, + + // === Help === + CommandEntry { + name: "Search Help", + description: "Activate help search", + category: "Help", + keybinding: "/", + pages: &[Help], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::HelpActivateSearch))), + }, + CommandEntry { + name: "Search Dictionary", + description: "Activate dictionary search", + category: "Help", + keybinding: "/", + pages: &[Dict], + normal_mode: false, + action: Some(PaletteAction::Resolve(|_| Some(AppCommand::DictActivateSearch))), + }, + + // ========================================================== + // Keybinding-only entries (action: None) — shown in help modal only + // ========================================================== + + // Global + CommandEntry { + name: "Navigate views", + description: "Switch between adjacent views", + category: "Navigation", + keybinding: "Ctrl+\u{2190}\u{2192}\u{2191}\u{2193}", + pages: &[], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Go to view", + description: "Dict/Patterns/Options/Help/Sequencer/Engine/Script", + category: "Navigation", + keybinding: "F1\u{2013}F7", + pages: &[], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Full restart", + description: "Full restart from step 0", + category: "Playback", + keybinding: "F12", + pages: &[], + normal_mode: true, + action: None, + }, + CommandEntry { + name: "Keybindings", + description: "Show keybindings help", + category: "Help", + keybinding: "?", + pages: &[], + normal_mode: false, + action: None, + }, + + // Main page keybinding-only + CommandEntry { + name: "Navigate", + description: "Move cursor between steps", + category: "Editing", + keybinding: "\u{2190}\u{2192}\u{2191}\u{2193}", + pages: &[Main], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Select", + description: "Extend selection", + category: "Editing", + keybinding: "Shift+\u{2190}\u{2192}\u{2191}\u{2193}", + pages: &[Main], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Clear Selection", + description: "Clear selection", + category: "Editing", + keybinding: "Esc", + pages: &[Main], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Samples", + description: "Toggle sample browser", + category: "Editing", + keybinding: "Tab", + pages: &[Main], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Delete", + description: "Delete step(s)", + category: "Editing", + keybinding: "Del", + pages: &[Main], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Run Step", + description: "Run step script immediately", + category: "Editing", + keybinding: "Ctrl+R", + pages: &[Main], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Fill", + description: "Toggle fill mode (hold)", + category: "Editing", + keybinding: "f", + pages: &[Main], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Command Palette", + description: "Open command palette", + category: "Editing", + keybinding: ":", + pages: &[], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Prev/Next Pattern", + description: "Previous/next pattern", + category: "Navigation", + keybinding: "Alt+\u{2191}\u{2193}", + pages: &[Main], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Prev/Next Bank", + description: "Previous/next bank", + category: "Navigation", + keybinding: "Alt+\u{2190}\u{2192}", + pages: &[Main], + normal_mode: false, + action: None, + }, + + // Patterns page keybinding-only + CommandEntry { + name: "Navigate", + description: "Move between banks/patterns", + category: "Navigation", + keybinding: "\u{2190}\u{2192}\u{2191}\u{2193}", + pages: &[Patterns], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Select", + description: "Extend selection", + category: "Navigation", + keybinding: "Shift+\u{2191}\u{2193}", + pages: &[Patterns], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Shift Patterns", + description: "Move patterns up/down", + category: "Pattern", + keybinding: "Alt+\u{2191}\u{2193}", + pages: &[Patterns], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Select Pattern", + description: "Select pattern for editing", + category: "Pattern", + keybinding: "Enter", + pages: &[Patterns], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Back", + description: "Clear armed or go back", + category: "Pattern", + keybinding: "Esc", + pages: &[Patterns], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Launch", + description: "Launch armed changes", + category: "Pattern", + keybinding: "c", + pages: &[Patterns], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Arm Play", + description: "Arm pattern play toggle", + category: "Pattern", + keybinding: "p", + pages: &[Patterns], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Rename", + description: "Rename bank/pattern", + category: "Pattern", + keybinding: "r", + pages: &[Patterns], + normal_mode: false, + action: None, + }, + + // Engine page keybinding-only + CommandEntry { + name: "Section", + description: "Next/previous section", + category: "Navigation", + keybinding: "Tab/BackTab", + pages: &[Engine], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Switch/Adjust", + description: "Switch device type or adjust setting", + category: "Audio", + keybinding: "\u{2190}\u{2192}", + pages: &[Engine], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Navigate", + description: "Navigate list items", + category: "Audio", + keybinding: "\u{2191}\u{2193}", + pages: &[Engine], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Page", + description: "Page through device list", + category: "Audio", + keybinding: "PgUp/Dn", + pages: &[Engine], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Select", + description: "Select device", + category: "Audio", + keybinding: "Enter", + pages: &[Engine], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Add Path", + description: "Add sample path", + category: "Audio", + keybinding: "A", + pages: &[Engine], + normal_mode: false, + action: None, + }, + + // Options page keybinding-only + CommandEntry { + name: "Next", + description: "Move to next option", + category: "Display", + keybinding: "Tab", + pages: &[Options], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Previous", + description: "Move to previous option", + category: "Display", + keybinding: "Shift+Tab", + pages: &[Options], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Navigate", + description: "Navigate options", + category: "Display", + keybinding: "\u{2191}\u{2193}", + pages: &[Options], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Toggle", + description: "Toggle or adjust option", + category: "Display", + keybinding: "\u{2190}\u{2192}", + pages: &[Options], + normal_mode: false, + action: None, + }, + + // Help page keybinding-only + CommandEntry { + name: "Scroll", + description: "Scroll content", + category: "Help", + keybinding: "\u{2191}\u{2193} j/k", + pages: &[Help], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Topic", + description: "Next/previous topic", + category: "Help", + keybinding: "Tab/BackTab", + pages: &[Help], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Page", + description: "Page scroll", + category: "Help", + keybinding: "PgUp/Dn", + pages: &[Help], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Next Code Block", + description: "Jump to next code block", + category: "Help", + keybinding: "n", + pages: &[Help], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Prev Code Block", + description: "Jump to previous code block", + category: "Help", + keybinding: "p", + pages: &[Help], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Run Code", + description: "Execute focused code block", + category: "Help", + keybinding: "Enter", + pages: &[Help], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Clear", + description: "Clear search / deselect block", + category: "Help", + keybinding: "Esc", + pages: &[Help], + normal_mode: false, + action: None, + }, + + // Dict page keybinding-only + CommandEntry { + name: "Focus", + description: "Toggle category/words focus", + category: "Help", + keybinding: "Tab", + pages: &[Dict], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Navigate", + description: "Navigate items", + category: "Help", + keybinding: "\u{2191}\u{2193} j/k", + pages: &[Dict], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Page", + description: "Page scroll", + category: "Help", + keybinding: "PgUp/Dn", + pages: &[Dict], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Clear", + description: "Clear search", + category: "Help", + keybinding: "Esc", + pages: &[Dict], + normal_mode: false, + action: None, + }, + + // Script page keybinding-only + CommandEntry { + name: "Focus", + description: "Focus editor for typing", + category: "Script", + keybinding: "Enter", + pages: &[Script], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Unfocus", + description: "Unfocus editor to use page keybindings", + category: "Script", + keybinding: "Esc", + pages: &[Script], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Evaluate", + description: "Compile and check for errors", + category: "Script", + keybinding: "Ctrl+E", + pages: &[Script], + normal_mode: false, + action: None, + }, + CommandEntry { + name: "Stack Preview", + description: "Toggle stack preview", + category: "Script", + keybinding: "Ctrl+S", + pages: &[Script], + normal_mode: false, + action: None, + }, + ] +}); + +pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'static str, &'static str)> { + COMMANDS + .iter() + .filter(|e| e.pages.is_empty() || e.pages.contains(&page)) + .filter(|e| !plugin_mode || !e.normal_mode) + .map(|e| (e.keybinding, e.name, e.description)) + .collect() +} + +pub fn palette_entries<'a>( + query: &str, + plugin_mode: bool, + app: &App, +) -> Vec<&'a CommandEntry> { + use cagire_ratatui::fuzzy_match; + + let filtered: Vec<(usize, &CommandEntry)> = COMMANDS + .iter() + .filter(|e| e.action.is_some()) + .filter(|e| !plugin_mode || !e.normal_mode) + .filter(|e| match &e.action { + Some(PaletteAction::Resolve(f)) => f(app).is_some(), + _ => true, + }) + .filter_map(|e| { + if query.is_empty() { + return Some((0, e)); + } + [ + fuzzy_match(query, e.name), + fuzzy_match(query, e.description), + fuzzy_match(query, e.category), + fuzzy_match(query, e.keybinding), + ] + .into_iter() + .flatten() + .min() + .map(|score| (score, e)) + }) + .collect(); + + let mut sorted = filtered; + sorted.sort_by_key(|(score, e)| (*score, category_index(e.category))); + sorted.into_iter().map(|(_, e)| e).collect() +} diff --git a/src/state/modal.rs b/src/state/modal.rs index e6a7c7d..f5ec885 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -74,7 +74,6 @@ pub enum Modal { input: String, }, SetTempo(String), - JumpToStep(String), AddSamplePath(Box), Editor, PatternProps { @@ -102,4 +101,9 @@ pub enum Modal { rotation: String, }, Onboarding { page: usize }, + CommandPalette { + input: String, + cursor: usize, + scroll: usize, + }, } diff --git a/src/views/keybindings.rs b/src/views/keybindings.rs deleted file mode 100644 index 42b3418..0000000 --- a/src/views/keybindings.rs +++ /dev/null @@ -1,144 +0,0 @@ -use crate::page::Page; - -pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'static str, &'static str)> { - let mut bindings = vec![ - ("F1–F7", "Go to view", "Dict/Patterns/Options/Help/Sequencer/Engine/Script"), - ("Ctrl+←→↑↓", "Navigate", "Switch between adjacent views"), - ]; - if !plugin_mode { - bindings.push(("q", "Quit", "Quit application")); - } - bindings.extend([ - ("s", "Save", "Save project"), - ("l", "Load", "Load project"), - ("?", "Keybindings", "Show this help"), - ("F12", "Restart", "Full restart from step 0"), - ]); - - // Page-specific bindings - match page { - Page::Main => { - if !plugin_mode { - bindings.push(("Space", "Play/Stop", "Toggle playback")); - } - bindings.push(("Alt+↑↓", "Pattern", "Previous/next pattern")); - bindings.push(("Alt+←→", "Bank", "Previous/next bank")); - 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", "Prelude", "Edit prelude 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")); - if !plugin_mode { - 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(("f", "Fill", "Toggle fill mode (hold)")); - bindings.push(("r", "Rename", "Rename current step")); - bindings.push(("Ctrl+R", "Run", "Run step script immediately")); - bindings.push((":", "Jump", "Jump to step number")); - bindings.push(("e", "Euclidean", "Distribute linked steps using Euclidean rhythm")); - bindings.push(("m", "Mute", "Arm mute for current pattern")); - bindings.push(("x", "Solo", "Arm solo for current pattern")); - bindings.push(("M", "Clear mutes", "Clear all mutes")); - bindings.push(("X", "Clear solos", "Clear all solos")); - bindings.push(("d", "Eval prelude", "Re-evaluate prelude without editing")); - bindings.push(("g", "Share", "Export pattern to clipboard")); - bindings.push(("G", "Import", "Import pattern from clipboard")); - } - Page::Patterns => { - bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns")); - bindings.push(("Shift+↑↓", "Select", "Extend selection")); - bindings.push(("Alt+↑↓", "Shift", "Move patterns up/down")); - bindings.push(("Enter", "Select", "Select pattern for editing")); - if !plugin_mode { - bindings.push(("Space", "Play", "Toggle pattern playback")); - } - bindings.push(("Esc", "Back", "Clear armed or go back")); - bindings.push(("c", "Launch", "Launch armed changes")); - bindings.push(("p", "Arm play", "Arm pattern play toggle")); - bindings.push(("r", "Rename", "Rename bank/pattern")); - bindings.push(("d", "Describe", "Add description to pattern")); - bindings.push(("e", "Properties", "Edit pattern properties")); - bindings.push(("m", "Mute", "Arm mute for pattern")); - bindings.push(("x", "Solo", "Arm solo for pattern")); - bindings.push(("M", "Clear mutes", "Clear all mutes")); - bindings.push(("X", "Clear solos", "Clear all solos")); - bindings.push(("g", "Share", "Export bank or pattern to clipboard")); - bindings.push(("G", "Import", "Import bank or pattern from clipboard")); - bindings.push(("Ctrl+C", "Copy", "Copy bank/pattern")); - bindings.push(("Ctrl+V", "Paste", "Paste bank/pattern")); - bindings.push(("Del", "Reset", "Reset bank/pattern")); - bindings.push(("Ctrl+Z", "Undo", "Undo last action")); - bindings.push(("Ctrl+Shift+Z", "Redo", "Redo last action")); - } - 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")); - if !plugin_mode { - 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")); - if !plugin_mode { - 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")); - if !plugin_mode { - 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(("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 / deselect block")); - } - 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")); - } - Page::Script => { - bindings.push(("Enter", "Focus", "Focus editor for typing")); - bindings.push(("Esc", "Unfocus", "Unfocus editor to use page keybindings")); - bindings.push(("Ctrl+E", "Evaluate", "Compile and check for errors (focused)")); - bindings.push(("S", "Set Speed", "Set script speed via text input (unfocused)")); - bindings.push(("L", "Set Length", "Set script length via text input (unfocused)")); - bindings.push(("Ctrl+S", "Stack", "Toggle stack preview (focused)")); - } - } - - bindings -} diff --git a/src/views/mod.rs b/src/views/mod.rs index c58eed9..0b4e270 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -2,7 +2,6 @@ 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/patterns_view.rs b/src/views/patterns_view.rs index 45a1566..cbf73d2 100644 --- a/src/views/patterns_view.rs +++ b/src/views/patterns_view.rs @@ -1,7 +1,7 @@ use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; +use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; use crate::app::App; @@ -359,8 +359,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a let cursor = app.patterns_nav.pattern_cursor; let available = inner.height as usize; - // Cursor row takes 2 lines (main + detail); account for 1 extra - let max_visible = available.saturating_sub(1).max(1); + let max_visible = available.max(1); let scroll_offset = if MAX_PATTERNS <= max_visible { 0 @@ -375,8 +374,6 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a let mut y = inner.y; for visible_idx in 0..visible_count { let idx = scroll_offset + visible_idx; - let is_expanded = idx == cursor; - let row_h = if is_expanded { 2u16 } else { 1u16 }; if y >= inner.y + inner.height { break; } @@ -385,7 +382,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a x: inner.x, y, width: inner.width, - height: row_h.min(inner.y + inner.height - y), + height: 1u16.min(inner.y + inner.height - y), }; let is_cursor = is_focused && idx == app.patterns_nav.pattern_cursor; @@ -471,21 +468,9 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a let base_style = Style::new().bg(bg).fg(fg); let bold_style = base_style.add_modifier(Modifier::BOLD); - let content_area = if is_expanded { - let border_color = if is_focused { theme.selection.cursor } else { theme.ui.unfocused }; - let block = Block::default() - .borders(Borders::LEFT | Borders::RIGHT) - .border_type(BorderType::QuadrantOutside) - .border_style(Style::new().fg(border_color).bg(bg)) - .style(Style::new().bg(bg)); - let content = block.inner(row_area); - frame.render_widget(block, row_area); - content - } else { - let bg_block = Block::default().style(Style::new().bg(bg)); - frame.render_widget(bg_block, row_area); - row_area - }; + let bg_block = Block::default().style(Style::new().bg(bg)); + frame.render_widget(bg_block, row_area); + let content_area = row_area; let text_area = Rect { x: content_area.x, @@ -521,16 +506,38 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a String::new() }; let props_indicator = if has_staged_props { "~" } else { "" }; - let right_info = if content_count > 0 { - format!("{props_indicator}{content_count}/{length}{speed_str}") + let quant_sync = if is_selected { + format!( + "{}:{} ", + pattern.quantization.short_label(), + pattern.sync_mode.short_label() + ) } else { - format!("{props_indicator} {length}{speed_str}") + String::new() + }; + let right_info = if content_count > 0 { + format!("{quant_sync}{props_indicator}{content_count}/{length}{speed_str}") + } else { + format!("{quant_sync}{props_indicator} {length}{speed_str}") }; let left_width: usize = spans.iter().map(|s| s.content.chars().count()).sum(); let right_width = right_info.chars().count(); - let padding = (text_area.width as usize).saturating_sub(left_width + right_width + 1); + let gap = (text_area.width as usize).saturating_sub(left_width + right_width + 1); - spans.push(Span::styled(" ".repeat(padding), dim_style)); + if let Some(desc) = pattern.description.as_deref().filter(|d| !d.is_empty() && gap > 4) { + let budget = gap - 2; + let char_count = desc.chars().count(); + if char_count <= budget { + spans.push(Span::styled(format!(" {desc}"), dim_style)); + spans.push(Span::styled(" ".repeat(gap - char_count - 1), dim_style)); + } else { + let truncated: String = desc.chars().take(budget - 1).collect(); + spans.push(Span::styled(format!(" {truncated}\u{2026}"), dim_style)); + spans.push(Span::styled(" ", dim_style)); + } + } else { + spans.push(Span::styled(" ".repeat(gap), dim_style)); + } spans.push(Span::styled(right_info, dim_style)); let spans = if is_playing && !is_cursor && !is_in_range { @@ -543,52 +550,6 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a frame.render_widget(Paragraph::new(Line::from(spans)), text_area); - if is_expanded && content_area.height >= 2 { - let detail_area = Rect { - x: content_area.x, - y: content_area.y + 1, - width: content_area.width, - height: 1, - }; - - let right_label = format!( - "{} · {}", - pattern.quantization.label(), - pattern.sync_mode.label() - ); - let w = detail_area.width as usize; - let label = if let Some(desc) = &pattern.description { - let right_len = right_label.chars().count(); - let max_desc = w.saturating_sub(right_len + 1); - let truncated: String = desc.chars().take(max_desc).collect(); - let pad = w.saturating_sub(truncated.chars().count() + right_len); - format!("{truncated}{}{right_label}", " ".repeat(pad)) - } else { - format!("{right_label:>w$}") - }; - let padded_label = label; - - let filled_width = if is_playing { - let ratio = snapshot.get_smooth_progress(bank, idx, length, speed.multiplier()).unwrap_or(0.0); - (ratio * detail_area.width as f64).min(detail_area.width as f64) as usize - } else { - 0 - }; - - let dim_fg = theme.ui.text_muted; - let progress_bg = theme.list.playing_bg; - let byte_offset = padded_label - .char_indices() - .nth(filled_width) - .map_or(padded_label.len(), |(i, _)| i); - let (left, right) = padded_label.split_at(byte_offset); - let detail_spans = vec![ - Span::styled(left.to_string(), Style::new().bg(progress_bg).fg(dim_fg)), - Span::styled(right.to_string(), Style::new().bg(theme.ui.bg).fg(dim_fg)), - ]; - frame.render_widget(Paragraph::new(Line::from(detail_spans)), detail_area); - } - y += row_area.height; } diff --git a/src/views/render.rs b/src/views/render.rs index b9a3c09..d2c4a7e 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -707,15 +707,6 @@ fn render_modal( .border_color(theme.modal.confirm) .render_centered(frame, term) } - Modal::JumpToStep(input) => { - let pattern_len = app.current_edit_pattern().length; - let title = format!("Jump to Step (1-{})", pattern_len); - TextInputModal::new(&title, input) - .hint("Enter step number") - .width(30) - .border_color(theme.modal.confirm) - .render_centered(frame, term) - } Modal::SetTempo(input) => TextInputModal::new("Set Tempo (20-300 BPM)", input) .hint("Enter BPM") .width(30) @@ -883,6 +874,9 @@ fn render_modal( inner } + Modal::CommandPalette { input, cursor, scroll } => { + render_command_palette(frame, app, input, *cursor, *scroll, term) + } Modal::KeybindingsHelp { scroll } => render_modal_keybindings(frame, app, *scroll, term), Modal::EuclideanDistribution { source_step, @@ -1086,6 +1080,247 @@ fn render_modal_editor( inner } +fn render_command_palette( + frame: &mut Frame, + app: &App, + query: &str, + cursor: usize, + scroll: usize, + term: Rect, +) -> Rect { + use crate::model::palette::{palette_entries, CommandEntry}; + + let theme = theme::get(); + let entries = palette_entries(query, app.plugin_mode, app); + + // On Main page, numeric input prepends a synthetic "Jump to Step" entry + let jump_step: Option = if app.page == Page::Main + && !query.is_empty() + && query.chars().all(|c| c.is_ascii_digit()) + { + query.parse().ok() + } else { + None + }; + + // Build display rows: each is either a separator header or a command entry + struct DisplayRow<'a> { + entry: Option<&'a CommandEntry>, + separator: Option<&'static str>, + is_jump: bool, + jump_label: String, + } + + let mut rows: Vec = Vec::new(); + + if let Some(n) = jump_step { + rows.push(DisplayRow { + entry: None, + separator: None, + is_jump: true, + jump_label: format!("Jump to Step {n}"), + }); + } + + if query.is_empty() { + // Grouped by category with separators + let mut last_category = ""; + for e in &entries { + if e.category != last_category { + rows.push(DisplayRow { + entry: None, + separator: Some(e.category), + is_jump: false, + jump_label: String::new(), + }); + last_category = e.category; + } + rows.push(DisplayRow { + entry: Some(e), + separator: None, + is_jump: false, + jump_label: String::new(), + }); + } + } else { + for e in &entries { + rows.push(DisplayRow { + entry: Some(e), + separator: None, + is_jump: false, + jump_label: String::new(), + }); + } + } + + // Count selectable items (non-separator) + let selectable_count = rows.iter().filter(|r| r.separator.is_none()).count(); + let cursor = cursor.min(selectable_count.saturating_sub(1)); + + let width: u16 = 55; + let max_height = (term.height as usize * 60 / 100).max(8); + let content_height = rows.len() + 4; // input + gap + hint + border padding + let height = content_height.min(max_height) as u16; + + let inner = ModalFrame::new(": Command Palette") + .width(width) + .height(height) + .border_color(theme.modal.confirm) + .render_centered(frame, term); + + let mut y = inner.y; + let content_width = inner.width; + + // Input line + let input_line = Line::from(vec![ + Span::styled("> ", Style::default().fg(theme.modal.confirm)), + Span::styled(query, Style::default().fg(theme.ui.text_primary)), + Span::styled("\u{2588}", Style::default().fg(theme.modal.confirm)), + ]); + frame.render_widget( + Paragraph::new(input_line), + Rect::new(inner.x, y, content_width, 1), + ); + y += 1; + + // Visible area for entries + let visible_height = inner.height.saturating_sub(2) as usize; // minus input line and hint line + + // Auto-scroll + let scroll = { + let mut s = scroll; + // Map cursor (selectable index) to row index for scrolling + let mut selectable_idx = 0; + let mut cursor_row = 0; + for (i, row) in rows.iter().enumerate() { + if row.separator.is_some() { + continue; + } + if selectable_idx == cursor { + cursor_row = i; + break; + } + selectable_idx += 1; + } + if cursor_row >= s + visible_height { + s = cursor_row + 1 - visible_height; + } + if cursor_row < s { + s = cursor_row; + } + s + }; + + // Render visible rows + let mut selectable_idx = rows.iter().take(scroll).filter(|r| r.separator.is_none()).count(); + for row in rows.iter().skip(scroll).take(visible_height) { + if y >= inner.y + inner.height - 1 { + break; + } + + if let Some(cat) = row.separator { + // Category header + let pad = content_width.saturating_sub(cat.len() as u16 + 4) / 2; + let sep_left = "\u{2500}".repeat(pad as usize); + let sep_right = + "\u{2500}".repeat(content_width.saturating_sub(pad + cat.len() as u16 + 4) as usize); + let line = Line::from(vec![ + Span::styled( + format!("{sep_left} "), + Style::default().fg(theme.ui.text_muted), + ), + Span::styled(cat, Style::default().fg(theme.ui.text_dim)), + Span::styled( + format!(" {sep_right}"), + Style::default().fg(theme.ui.text_muted), + ), + ]); + frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, content_width, 1)); + y += 1; + continue; + } + + let is_selected = selectable_idx == cursor; + let (bg, fg) = if is_selected { + (theme.selection.cursor_bg, theme.selection.cursor_fg) + } else if selectable_idx.is_multiple_of(2) { + (theme.table.row_even, theme.ui.text_primary) + } else { + (theme.table.row_odd, theme.ui.text_primary) + }; + + let (name, keybinding) = if row.is_jump { + (row.jump_label.as_str(), "") + } else if let Some(e) = row.entry { + (e.name, e.keybinding) + } else { + selectable_idx += 1; + y += 1; + continue; + }; + + let key_len = keybinding.len() as u16; + let name_width = content_width.saturating_sub(key_len + 2); + let truncated_name: String = name.chars().take(name_width as usize).collect(); + let padding = name_width.saturating_sub(truncated_name.len() as u16); + + let key_fg = if is_selected { + theme.selection.cursor_fg + } else { + theme.ui.text_dim + }; + + let line = Line::from(vec![ + Span::styled(format!(" {truncated_name}"), Style::default().bg(bg).fg(fg)), + Span::styled( + " ".repeat(padding as usize), + Style::default().bg(bg), + ), + Span::styled( + format!("{keybinding} "), + Style::default().bg(bg).fg(key_fg), + ), + ]); + frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, content_width, 1)); + + selectable_idx += 1; + y += 1; + } + + // Empty state + if selectable_count == 0 { + let msg = "No matching commands"; + let empty_y = inner.y + inner.height / 2; + if empty_y < inner.y + inner.height - 1 { + frame.render_widget( + Paragraph::new(msg) + .style(Style::default().fg(theme.ui.text_muted)) + .alignment(Alignment::Center), + Rect::new(inner.x, empty_y, content_width, 1), + ); + } + } + + // Hint bar + let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); + let hints = if jump_step.is_some() && cursor == 0 { + hint_line(&[ + ("\u{2191}\u{2193}", "navigate"), + ("Enter", "jump to step"), + ("Esc", "close"), + ]) + } else { + hint_line(&[ + ("\u{2191}\u{2193}", "navigate"), + ("Enter", "run"), + ("Esc", "close"), + ]) + }; + frame.render_widget(Paragraph::new(hints), hint_area); + + inner +} + fn render_modal_keybindings(frame: &mut Frame, app: &App, scroll: usize, term: Rect) -> Rect { let theme = theme::get(); let width = (term.width * 80 / 100).clamp(60, 100); @@ -1098,7 +1333,7 @@ fn render_modal_keybindings(frame: &mut Frame, app: &App, scroll: usize, term: R .border_color(theme.modal.editor) .render_centered(frame, term); - let bindings = super::keybindings::bindings_for(app.page, app.plugin_mode); + let bindings = crate::model::palette::bindings_for(app.page, app.plugin_mode); let visible_rows = inner.height.saturating_sub(2) as usize; let rows: Vec = bindings