use arc_swap::ArcSwap; use crossbeam_channel::Sender; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use crate::app::App; use crate::commands::AppCommand; use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot}; use crate::model::PatternSpeed; use crate::page::Page; use crate::state::{ CyclicEnum, DeviceKind, EngineSection, EuclideanField, Modal, OptionsFocus, PanelFocus, PatternField, PatternPropsField, SampleBrowserState, SettingKind, SidePanel, }; pub enum InputResult { Continue, Quit, } pub struct InputContext<'a> { pub app: &'a mut App, pub link: &'a LinkState, pub snapshot: &'a SequencerSnapshot, pub playing: &'a Arc, pub audio_tx: &'a ArcSwap>, pub seq_cmd_tx: &'a Sender, pub nudge_us: &'a Arc, } impl<'a> InputContext<'a> { fn dispatch(&mut self, cmd: AppCommand) { self.app.dispatch(cmd, self.link, self.snapshot); } } pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult { if handle_live_keys(ctx, &key) { return InputResult::Continue; } if key.kind == KeyEventKind::Release { return InputResult::Continue; } let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let is_arrow = matches!( key.code, KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down ); if ctx.app.ui.minimap_until.is_some() && !(ctrl && is_arrow) { ctx.dispatch(AppCommand::ClearMinimap); } if ctx.app.ui.show_title { ctx.dispatch(AppCommand::HideTitle); return InputResult::Continue; } ctx.dispatch(AppCommand::ClearStatus); if matches!(ctx.app.ui.modal, Modal::None) { handle_normal_input(ctx, key) } else { handle_modal_input(ctx, key) } } fn handle_live_keys(ctx: &mut InputContext, key: &KeyEvent) -> bool { match (key.code, key.kind) { _ if !matches!(ctx.app.ui.modal, Modal::None) => false, (KeyCode::Char('f'), KeyEventKind::Press) => { ctx.dispatch(AppCommand::ToggleLiveKeysFill); true } _ => false, } } fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match &mut ctx.app.ui.modal { Modal::ConfirmQuit { selected } => match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => return InputResult::Quit, KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { ctx.dispatch(AppCommand::CloseModal); } KeyCode::Left | KeyCode::Right => { *selected = !*selected; } KeyCode::Enter => { if *selected { return InputResult::Quit; } else { ctx.dispatch(AppCommand::CloseModal); } } _ => {} }, Modal::ConfirmDeleteStep { bank, pattern, step, selected: _, } => { let (bank, pattern, step) = (*bank, *pattern, *step); match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { ctx.dispatch(AppCommand::DeleteStep { bank, pattern, step, }); ctx.dispatch(AppCommand::CloseModal); } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { ctx.dispatch(AppCommand::CloseModal); } KeyCode::Left | KeyCode::Right => { if let Modal::ConfirmDeleteStep { selected, .. } = &mut ctx.app.ui.modal { *selected = !*selected; } } KeyCode::Enter => { let do_delete = if let Modal::ConfirmDeleteStep { selected, .. } = &ctx.app.ui.modal { *selected } else { false }; if do_delete { ctx.dispatch(AppCommand::DeleteStep { bank, pattern, step, }); } ctx.dispatch(AppCommand::CloseModal); } _ => {} } } Modal::ConfirmDeleteSteps { bank, pattern, steps, selected: _, } => { let (bank, pattern, steps) = (*bank, *pattern, steps.clone()); match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { ctx.dispatch(AppCommand::DeleteSteps { bank, pattern, steps, }); ctx.dispatch(AppCommand::CloseModal); } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { ctx.dispatch(AppCommand::CloseModal); } KeyCode::Left | KeyCode::Right => { if let Modal::ConfirmDeleteSteps { selected, .. } = &mut ctx.app.ui.modal { *selected = !*selected; } } KeyCode::Enter => { let do_delete = if let Modal::ConfirmDeleteSteps { selected, .. } = &ctx.app.ui.modal { *selected } else { false }; if do_delete { ctx.dispatch(AppCommand::DeleteSteps { bank, pattern, steps, }); } ctx.dispatch(AppCommand::CloseModal); } _ => {} } } Modal::ConfirmResetPattern { bank, pattern, selected: _, } => { let (bank, pattern) = (*bank, *pattern); match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { ctx.dispatch(AppCommand::ResetPattern { bank, pattern }); ctx.dispatch(AppCommand::CloseModal); } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { ctx.dispatch(AppCommand::CloseModal); } KeyCode::Left | KeyCode::Right => { if let Modal::ConfirmResetPattern { selected, .. } = &mut ctx.app.ui.modal { *selected = !*selected; } } KeyCode::Enter => { let do_reset = if let Modal::ConfirmResetPattern { selected, .. } = &ctx.app.ui.modal { *selected } else { false }; if do_reset { ctx.dispatch(AppCommand::ResetPattern { bank, pattern }); } ctx.dispatch(AppCommand::CloseModal); } _ => {} } } Modal::ConfirmResetBank { bank, selected: _ } => { let bank = *bank; match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { ctx.dispatch(AppCommand::ResetBank { bank }); ctx.dispatch(AppCommand::CloseModal); } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { ctx.dispatch(AppCommand::CloseModal); } KeyCode::Left | KeyCode::Right => { if let Modal::ConfirmResetBank { selected, .. } = &mut ctx.app.ui.modal { *selected = !*selected; } } KeyCode::Enter => { let do_reset = if let Modal::ConfirmResetBank { selected, .. } = &ctx.app.ui.modal { *selected } else { false }; if do_reset { ctx.dispatch(AppCommand::ResetBank { bank }); } ctx.dispatch(AppCommand::CloseModal); } _ => {} } } Modal::FileBrowser(state) => match key.code { KeyCode::Enter => { use crate::state::file_browser::FileBrowserMode; let mode = state.mode.clone(); if let Some(path) = state.confirm() { ctx.dispatch(AppCommand::CloseModal); match mode { FileBrowserMode::Save => ctx.dispatch(AppCommand::Save(path)), FileBrowserMode::Load => { let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll); let _ = ctx.seq_cmd_tx.send(SeqCommand::ResetScriptState); ctx.dispatch(AppCommand::Load(path)); load_project_samples(ctx); } } } } KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), KeyCode::Tab => state.autocomplete(), KeyCode::Left => state.go_up(), KeyCode::Right => state.enter_selected(), KeyCode::Up => state.select_prev(12), KeyCode::Down => state.select_next(12), KeyCode::Backspace => state.backspace(), KeyCode::Char(c) => { state.input.push(c); state.refresh_entries(); } _ => {} }, Modal::RenameBank { bank, name } => match key.code { KeyCode::Enter => { let bank_idx = *bank; let new_name = if name.trim().is_empty() { None } else { Some(name.clone()) }; ctx.dispatch(AppCommand::RenameBank { bank: bank_idx, name: new_name, }); ctx.dispatch(AppCommand::CloseModal); } KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), KeyCode::Backspace => { name.pop(); } KeyCode::Char(c) => name.push(c), _ => {} }, Modal::RenamePattern { bank, pattern, name, } => match key.code { KeyCode::Enter => { let (bank_idx, pattern_idx) = (*bank, *pattern); let new_name = if name.trim().is_empty() { None } else { Some(name.clone()) }; ctx.dispatch(AppCommand::RenamePattern { bank: bank_idx, pattern: pattern_idx, name: new_name, }); ctx.dispatch(AppCommand::CloseModal); } KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), KeyCode::Backspace => { name.pop(); } KeyCode::Char(c) => name.push(c), _ => {} }, Modal::RenameStep { bank, pattern, step, name, } => match key.code { KeyCode::Enter => { let (bank_idx, pattern_idx, step_idx) = (*bank, *pattern, *step); let new_name = if name.trim().is_empty() { None } else { Some(name.clone()) }; ctx.dispatch(AppCommand::RenameStep { bank: bank_idx, pattern: pattern_idx, step: step_idx, name: new_name, }); ctx.dispatch(AppCommand::CloseModal); } KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), KeyCode::Backspace => { name.pop(); } KeyCode::Char(c) => name.push(c), _ => {} }, Modal::SetPattern { field, input } => match key.code { KeyCode::Enter => { let field = *field; let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); match field { PatternField::Length => { if let Ok(len) = input.parse::() { ctx.dispatch(AppCommand::SetLength { bank, pattern, length: len, }); let new_len = ctx .app .project_state .project .pattern_at(bank, pattern) .length; ctx.dispatch(AppCommand::SetStatus(format!("Length set to {new_len}"))); } else { ctx.dispatch(AppCommand::SetStatus("Invalid length".to_string())); } } PatternField::Speed => { if let Some(speed) = PatternSpeed::from_label(input) { ctx.dispatch(AppCommand::SetSpeed { bank, pattern, speed, }); ctx.dispatch(AppCommand::SetStatus(format!( "Speed set to {}", speed.label() ))); } else { ctx.dispatch(AppCommand::SetStatus( "Invalid speed (try 1/3, 2/5, 1x, 2x)".to_string(), )); } } } ctx.dispatch(AppCommand::CloseModal); } KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), KeyCode::Backspace => { input.pop(); } KeyCode::Char(c) => input.push(c), _ => {} }, Modal::SetTempo(input) => match key.code { KeyCode::Enter => { if let Ok(tempo) = input.parse::() { let tempo = tempo.clamp(20.0, 300.0); ctx.link.set_tempo(tempo); ctx.dispatch(AppCommand::SetStatus(format!( "Tempo set to {tempo:.1} BPM" ))); } else { ctx.dispatch(AppCommand::SetStatus("Invalid tempo".to_string())); } ctx.dispatch(AppCommand::CloseModal); } KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), KeyCode::Backspace => { input.pop(); } KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => input.push(c), _ => {} }, Modal::AddSamplePath(state) => match key.code { KeyCode::Enter => { let sample_path = if let Some(entry) = state.entries.get(state.selected) { if entry.is_dir && entry.name != ".." { Some(state.current_dir().join(&entry.name)) } else if entry.is_dir { state.enter_selected(); None } else { None } } else { let dir = state.current_dir(); if dir.is_dir() { Some(dir) } else { None } }; if let Some(path) = sample_path { let index = doux::sampling::scan_samples_dir(&path); let count = index.len(); let _ = ctx.audio_tx.load().send(AudioCommand::LoadSamples(index)); ctx.app.audio.config.sample_count += count; ctx.app.audio.add_sample_path(path); ctx.dispatch(AppCommand::SetStatus(format!("Added {count} samples"))); ctx.dispatch(AppCommand::CloseModal); } } KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), KeyCode::Tab => state.autocomplete(), KeyCode::Left => state.go_up(), KeyCode::Right => state.enter_selected(), KeyCode::Up => state.select_prev(14), KeyCode::Down => state.select_next(14), KeyCode::Backspace => state.backspace(), KeyCode::Char(c) => { state.input.push(c); state.refresh_entries(); } _ => {} }, Modal::Editor => { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let shift = key.modifiers.contains(KeyModifiers::SHIFT); let editor = &mut ctx.app.editor_ctx.editor; if editor.search_active() { match key.code { KeyCode::Esc => editor.search_clear(), KeyCode::Enter => editor.search_confirm(), KeyCode::Backspace => editor.search_backspace(), KeyCode::Char(c) if !ctrl => editor.search_input(c), _ => {} } return InputResult::Continue; } match key.code { KeyCode::Esc => { if editor.is_selecting() { editor.cancel_selection(); } else if editor.completion_active() { editor.dismiss_completion(); } else { ctx.dispatch(AppCommand::SaveEditorToStep); ctx.dispatch(AppCommand::CompileCurrentStep); ctx.dispatch(AppCommand::CloseModal); } } KeyCode::Char('e') if ctrl => { ctx.dispatch(AppCommand::SaveEditorToStep); ctx.dispatch(AppCommand::CompileCurrentStep); } KeyCode::Char('f') if ctrl => { editor.activate_search(); } KeyCode::Char('n') if ctrl => { if editor.completion_active() { editor.completion_next(); } else { editor.search_next(); } } KeyCode::Char('p') if ctrl => { if editor.completion_active() { editor.completion_prev(); } else { editor.search_prev(); } } KeyCode::Char('s') if ctrl => { ctx.dispatch(AppCommand::ToggleEditorStack); } KeyCode::Char('r') if ctrl => { let script = ctx.app.editor_ctx.editor.lines().join("\n"); match ctx .app .execute_script_oneshot(&script, ctx.link, ctx.audio_tx) { Ok(()) => ctx .app .ui .flash("Executed", 100, crate::state::FlashKind::Info), Err(e) => ctx.app.ui.flash( &format!("Error: {e}"), 200, crate::state::FlashKind::Error, ), } } KeyCode::Char('a') if ctrl => { editor.select_all(); } KeyCode::Char('c') if ctrl => { editor.copy(); } KeyCode::Char('x') if ctrl => { editor.cut(); } KeyCode::Char('v') if ctrl => { editor.paste(); } KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down if shift => { if !editor.is_selecting() { editor.start_selection(); } editor.input(Event::Key(key)); } _ => { editor.input(Event::Key(key)); } } if ctx.app.editor_ctx.show_stack { crate::services::stack_preview::update_cache(&ctx.app.editor_ctx); } } Modal::Preview => match key.code { KeyCode::Esc | KeyCode::Char('p') => ctx.dispatch(AppCommand::CloseModal), KeyCode::Left => ctx.dispatch(AppCommand::PrevStep), KeyCode::Right => ctx.dispatch(AppCommand::NextStep), KeyCode::Up => ctx.dispatch(AppCommand::StepUp), KeyCode::Down => ctx.dispatch(AppCommand::StepDown), _ => {} }, Modal::PatternProps { bank, pattern, field, name, length, speed, quantization, sync_mode, } => { let (bank, pattern) = (*bank, *pattern); match key.code { KeyCode::Up => *field = field.prev(), KeyCode::Down | KeyCode::Tab => *field = field.next(), KeyCode::Left => match field { PatternPropsField::Speed => *speed = speed.prev(), PatternPropsField::Quantization => *quantization = quantization.prev(), PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(), _ => {} }, KeyCode::Right => match field { PatternPropsField::Speed => *speed = speed.next(), PatternPropsField::Quantization => *quantization = quantization.next(), PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(), _ => {} }, KeyCode::Char(c) => match field { PatternPropsField::Name => name.push(c), PatternPropsField::Length if c.is_ascii_digit() => length.push(c), _ => {} }, KeyCode::Backspace => match field { PatternPropsField::Name => { name.pop(); } PatternPropsField::Length => { length.pop(); } _ => {} }, KeyCode::Enter => { let name_val = if name.is_empty() { None } else { Some(name.clone()) }; let length_val = length.parse().ok(); let speed_val = *speed; let quant_val = *quantization; let sync_val = *sync_mode; ctx.dispatch(AppCommand::StagePatternProps { bank, pattern, name: name_val, length: length_val, speed: speed_val, quantization: quant_val, sync_mode: sync_val, }); ctx.dispatch(AppCommand::CloseModal); } KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), _ => {} } } 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::EuclideanDistribution { bank, pattern, source_step, field, pulses, steps, rotation, } => { let (bank_val, pattern_val, source_step_val) = (*bank, *pattern, *source_step); match key.code { KeyCode::Up => *field = field.prev(), KeyCode::Down | KeyCode::Tab => *field = field.next(), KeyCode::Left => { let target = match field { EuclideanField::Pulses => pulses, EuclideanField::Steps => steps, EuclideanField::Rotation => rotation, }; if let Ok(val) = target.parse::() { *target = val.saturating_sub(1).to_string(); } } KeyCode::Right => { let target = match field { EuclideanField::Pulses => pulses, EuclideanField::Steps => steps, EuclideanField::Rotation => rotation, }; if let Ok(val) = target.parse::() { *target = (val + 1).min(128).to_string(); } } KeyCode::Char(c) if c.is_ascii_digit() => match field { EuclideanField::Pulses => pulses.push(c), EuclideanField::Steps => steps.push(c), EuclideanField::Rotation => rotation.push(c), }, KeyCode::Backspace => match field { EuclideanField::Pulses => { pulses.pop(); } EuclideanField::Steps => { steps.pop(); } EuclideanField::Rotation => { rotation.pop(); } }, KeyCode::Enter => { let pulses_val: usize = pulses.parse().unwrap_or(0); let steps_val: usize = steps.parse().unwrap_or(0); let rotation_val: usize = rotation.parse().unwrap_or(0); if pulses_val > 0 && steps_val > 0 && pulses_val <= steps_val { ctx.dispatch(AppCommand::ApplyEuclideanDistribution { bank: bank_val, pattern: pattern_val, source_step: source_step_val, pulses: pulses_val, steps: steps_val, rotation: rotation_val, }); ctx.dispatch(AppCommand::CloseModal); } else { ctx.dispatch(AppCommand::SetStatus( "Invalid: pulses must be > 0 and <= steps".to_string(), )); } } KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), _ => {} } } Modal::None => unreachable!(), } InputResult::Continue } fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); if ctx.app.panel.visible && ctx.app.panel.focus == PanelFocus::Side { return handle_panel_input(ctx, key); } if ctrl { let minimap_timeout = Some(Instant::now() + Duration::from_millis(250)); match key.code { KeyCode::Left => { ctx.app.ui.minimap_until = minimap_timeout; ctx.dispatch(AppCommand::PageLeft); return InputResult::Continue; } KeyCode::Right => { ctx.app.ui.minimap_until = minimap_timeout; ctx.dispatch(AppCommand::PageRight); return InputResult::Continue; } KeyCode::Up => { ctx.app.ui.minimap_until = minimap_timeout; ctx.dispatch(AppCommand::PageUp); return InputResult::Continue; } KeyCode::Down => { ctx.app.ui.minimap_until = minimap_timeout; ctx.dispatch(AppCommand::PageDown); return InputResult::Continue; } _ => {} } } match ctx.app.page { Page::Main => handle_main_page(ctx, key, ctrl), Page::Patterns => handle_patterns_page(ctx, key), Page::Engine => handle_engine_page(ctx, key), Page::Options => handle_options_page(ctx, key), Page::Help => handle_help_page(ctx, key), Page::Dict => handle_dict_page(ctx, key), } } fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { use crate::engine::AudioCommand; use cagire_ratatui::TreeLineKind; let state = match &mut ctx.app.panel.side { Some(SidePanel::SampleBrowser(s)) => s, None => return InputResult::Continue, }; let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); if state.search_active { match key.code { KeyCode::Esc => { state.clear_search(); } KeyCode::Backspace => { state.search_query.pop(); state.update_search(); } KeyCode::Enter => { state.search_active = false; } KeyCode::Char(c) => { state.search_query.push(c); state.update_search(); } _ => {} } } else if ctrl { match key.code { KeyCode::Up => { for _ in 0..10 { state.move_up(); } } KeyCode::Down => { for _ in 0..10 { state.move_down(30); } } _ => {} } } else { match key.code { KeyCode::Up | KeyCode::Char('k') => state.move_up(), KeyCode::Down | KeyCode::Char('j') => state.move_down(30), KeyCode::Enter | KeyCode::Right => { if let Some(entry) = state.current_entry() { match entry.kind { TreeLineKind::File => { let folder = &entry.folder; let idx = entry.index; let cmd = format!("/sound/{folder}/n/{idx}/gain/0.5/dur/1"); let _ = ctx .audio_tx .load() .send(AudioCommand::Evaluate { cmd, time: None }); } _ => state.toggle_expand(), } } } KeyCode::Left => state.collapse_at_cursor(), KeyCode::Char('/') => state.activate_search(), KeyCode::Esc | KeyCode::Tab => { ctx.dispatch(AppCommand::ClosePanel); } _ => {} } } InputResult::Continue } fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputResult { let shift = key.modifiers.contains(KeyModifiers::SHIFT); match key.code { KeyCode::Tab => { if ctx.app.panel.visible { ctx.app.panel.visible = false; ctx.app.panel.focus = PanelFocus::Main; } else { if ctx.app.panel.side.is_none() { let state = SampleBrowserState::new(&ctx.app.audio.config.sample_paths); ctx.app.panel.side = Some(SidePanel::SampleBrowser(state)); } ctx.app.panel.visible = true; ctx.app.panel.focus = PanelFocus::Side; } } KeyCode::Char('q') => { ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { selected: false, })); } KeyCode::Char(' ') => { ctx.dispatch(AppCommand::TogglePlaying); ctx.playing .store(ctx.app.playback.playing, Ordering::Relaxed); } KeyCode::Left if shift && !ctrl => { if ctx.app.editor_ctx.selection_anchor.is_none() { ctx.dispatch(AppCommand::SetSelectionAnchor(ctx.app.editor_ctx.step)); } ctx.dispatch(AppCommand::PrevStep); } KeyCode::Right if shift && !ctrl => { if ctx.app.editor_ctx.selection_anchor.is_none() { ctx.dispatch(AppCommand::SetSelectionAnchor(ctx.app.editor_ctx.step)); } ctx.dispatch(AppCommand::NextStep); } KeyCode::Up if shift && !ctrl => { if ctx.app.editor_ctx.selection_anchor.is_none() { ctx.dispatch(AppCommand::SetSelectionAnchor(ctx.app.editor_ctx.step)); } ctx.dispatch(AppCommand::StepUp); } KeyCode::Down if shift && !ctrl => { if ctx.app.editor_ctx.selection_anchor.is_none() { ctx.dispatch(AppCommand::SetSelectionAnchor(ctx.app.editor_ctx.step)); } ctx.dispatch(AppCommand::StepDown); } KeyCode::Left => { ctx.app.editor_ctx.clear_selection(); ctx.dispatch(AppCommand::PrevStep); } KeyCode::Right => { ctx.app.editor_ctx.clear_selection(); ctx.dispatch(AppCommand::NextStep); } KeyCode::Up => { ctx.app.editor_ctx.clear_selection(); ctx.dispatch(AppCommand::StepUp); } KeyCode::Down => { ctx.app.editor_ctx.clear_selection(); ctx.dispatch(AppCommand::StepDown); } KeyCode::Esc => { ctx.app.editor_ctx.clear_selection(); } KeyCode::Enter => { ctx.app.editor_ctx.clear_selection(); 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(state))); } KeyCode::Char('c') if ctrl => { ctx.dispatch(AppCommand::CopySteps); } KeyCode::Char('v') if ctrl => { ctx.dispatch(AppCommand::PasteSteps); } KeyCode::Char('b') if ctrl => { ctx.dispatch(AppCommand::LinkPasteSteps); } KeyCode::Char('d') if ctrl => { 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(state))); } KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp), KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown), KeyCode::Char('T') => { let current = format!("{:.1}", ctx.link.tempo()); ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current))); } KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease), KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease), KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease), KeyCode::Char(']') => ctx.dispatch(AppCommand::SpeedIncrease), KeyCode::Char('L') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Length)), KeyCode::Char('S') => ctx.dispatch(AppCommand::OpenPatternModal(PatternField::Speed)), KeyCode::Char('p') => ctx.dispatch(AppCommand::OpenModal(Modal::Preview)), KeyCode::Delete | KeyCode::Backspace => { let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); if let Some(range) = ctx.app.editor_ctx.selection_range() { let steps: Vec = range.collect(); ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmDeleteSteps { bank, pattern, steps, selected: false, })); } else { let step = ctx.app.editor_ctx.step; ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmDeleteStep { bank, pattern, step, selected: false, })); } } KeyCode::Char('r') if ctrl => { let pattern = ctx.app.current_edit_pattern(); if let Some(script) = pattern.resolve_script(ctx.app.editor_ctx.step) { if !script.trim().is_empty() { match ctx .app .execute_script_oneshot(script, ctx.link, ctx.audio_tx) { Ok(()) => ctx .app .ui .flash("Executed", 100, crate::state::FlashKind::Info), Err(e) => ctx.app.ui.flash( &format!("Error: {e}"), 200, crate::state::FlashKind::Error, ), } } } } KeyCode::Char('r') => { let (bank, pattern, step) = ( ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern, ctx.app.editor_ctx.step, ); let current_name = ctx .app .current_edit_pattern() .step(step) .and_then(|s| s.name.clone()) .unwrap_or_default(); ctx.dispatch(AppCommand::OpenModal(Modal::RenameStep { bank, pattern, step, name: current_name, })); } KeyCode::Char('o') => { ctx.app.audio.config.layout = ctx.app.audio.config.layout.next(); } KeyCode::Char('?') => { ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); } KeyCode::Char('e') | KeyCode::Char('E') => { let (bank, pattern, step) = ( ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern, ctx.app.editor_ctx.step, ); let pattern_len = ctx.app.current_edit_pattern().length; let default_steps = pattern_len.min(32); let default_pulses = (default_steps / 2).max(1).min(default_steps); ctx.dispatch(AppCommand::OpenModal(Modal::EuclideanDistribution { bank, pattern, source_step: step, field: EuclideanField::Pulses, pulses: default_pulses.to_string(), steps: default_steps.to_string(), rotation: "0".to_string(), })); } KeyCode::Char('m') => { let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); ctx.dispatch(AppCommand::StageMute { bank, pattern }); } KeyCode::Char('x') => { let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); ctx.dispatch(AppCommand::StageSolo { bank, pattern }); } KeyCode::Char('M') => { ctx.dispatch(AppCommand::ClearMutes); ctx.app.send_mute_state(ctx.seq_cmd_tx); } KeyCode::Char('X') => { ctx.dispatch(AppCommand::ClearSolos); ctx.app.send_mute_state(ctx.seq_cmd_tx); } _ => {} } InputResult::Continue } fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { use crate::state::PatternsColumn; let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { KeyCode::Left => ctx.dispatch(AppCommand::PatternsCursorLeft), KeyCode::Right => ctx.dispatch(AppCommand::PatternsCursorRight), KeyCode::Up => ctx.dispatch(AppCommand::PatternsCursorUp), KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown), KeyCode::Esc => { if !ctx.app.playback.staged_changes.is_empty() || !ctx.app.playback.staged_mute_changes.is_empty() || !ctx.app.playback.staged_prop_changes.is_empty() { ctx.dispatch(AppCommand::ClearStagedChanges); } else { ctx.dispatch(AppCommand::PatternsBack); } } KeyCode::Enter => ctx.dispatch(AppCommand::PatternsEnter), KeyCode::Char(' ') => { if ctx.app.patterns_nav.column == PatternsColumn::Patterns { ctx.dispatch(AppCommand::PatternsTogglePlay); } } KeyCode::Char('c') if !ctrl => { let mute_changed = ctx.app.commit_staged_changes(); if mute_changed { ctx.app.send_mute_state(ctx.seq_cmd_tx); } } KeyCode::Char('q') => { ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { selected: false, })); } KeyCode::Char('c') if ctrl => { let bank = ctx.app.patterns_nav.bank_cursor; match ctx.app.patterns_nav.column { PatternsColumn::Banks => { ctx.dispatch(AppCommand::CopyBank { bank }); } PatternsColumn::Patterns => { let pattern = ctx.app.patterns_nav.pattern_cursor; ctx.dispatch(AppCommand::CopyPattern { bank, pattern }); } } } KeyCode::Char('v') if ctrl => { let bank = ctx.app.patterns_nav.bank_cursor; match ctx.app.patterns_nav.column { PatternsColumn::Banks => { ctx.dispatch(AppCommand::PasteBank { bank }); } PatternsColumn::Patterns => { let pattern = ctx.app.patterns_nav.pattern_cursor; ctx.dispatch(AppCommand::PastePattern { bank, pattern }); } } } KeyCode::Delete | KeyCode::Backspace => { let bank = ctx.app.patterns_nav.bank_cursor; match ctx.app.patterns_nav.column { PatternsColumn::Banks => { ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetBank { bank, selected: false, })); } PatternsColumn::Patterns => { let pattern = ctx.app.patterns_nav.pattern_cursor; ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetPattern { bank, pattern, selected: false, })); } } } KeyCode::Char('r') => { let bank = ctx.app.patterns_nav.bank_cursor; match ctx.app.patterns_nav.column { PatternsColumn::Banks => { let current_name = ctx.app.project_state.project.banks[bank] .name .clone() .unwrap_or_default(); ctx.dispatch(AppCommand::OpenModal(Modal::RenameBank { bank, name: current_name, })); } PatternsColumn::Patterns => { let pattern = ctx.app.patterns_nav.pattern_cursor; let current_name = ctx.app.project_state.project.banks[bank].patterns[pattern] .name .clone() .unwrap_or_default(); ctx.dispatch(AppCommand::OpenModal(Modal::RenamePattern { bank, pattern, name: current_name, })); } } } KeyCode::Char('e') if !ctrl => { if ctx.app.patterns_nav.column == PatternsColumn::Patterns { let bank = ctx.app.patterns_nav.bank_cursor; let pattern = ctx.app.patterns_nav.pattern_cursor; ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern }); } } KeyCode::Char('m') => { let bank = ctx.app.patterns_nav.bank_cursor; let pattern = ctx.app.patterns_nav.pattern_cursor; ctx.dispatch(AppCommand::StageMute { bank, pattern }); } KeyCode::Char('x') => { let bank = ctx.app.patterns_nav.bank_cursor; let pattern = ctx.app.patterns_nav.pattern_cursor; ctx.dispatch(AppCommand::StageSolo { bank, pattern }); } KeyCode::Char('M') => { ctx.dispatch(AppCommand::ClearMutes); ctx.app.send_mute_state(ctx.seq_cmd_tx); } KeyCode::Char('X') => { ctx.dispatch(AppCommand::ClearSolos); ctx.app.send_mute_state(ctx.seq_cmd_tx); } KeyCode::Char('?') => { ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); } _ => {} } InputResult::Continue } fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match key.code { KeyCode::Char('q') => { ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { selected: false, })); } KeyCode::Tab => ctx.dispatch(AppCommand::AudioNextSection), KeyCode::BackTab => ctx.dispatch(AppCommand::AudioPrevSection), KeyCode::Up => match ctx.app.audio.section { EngineSection::Devices => match ctx.app.audio.device_kind { DeviceKind::Output => ctx.dispatch(AppCommand::AudioOutputListUp), DeviceKind::Input => ctx.dispatch(AppCommand::AudioInputListUp), }, EngineSection::Settings => { ctx.dispatch(AppCommand::AudioSettingPrev); } EngineSection::Samples => {} }, KeyCode::Down => match ctx.app.audio.section { EngineSection::Devices => match ctx.app.audio.device_kind { DeviceKind::Output => { let count = ctx.app.audio.output_devices.len(); ctx.dispatch(AppCommand::AudioOutputListDown(count)); } DeviceKind::Input => { let count = ctx.app.audio.input_devices.len(); ctx.dispatch(AppCommand::AudioInputListDown(count)); } }, EngineSection::Settings => { ctx.dispatch(AppCommand::AudioSettingNext); } EngineSection::Samples => {} }, KeyCode::PageUp => { if ctx.app.audio.section == EngineSection::Devices { match ctx.app.audio.device_kind { DeviceKind::Output => ctx.dispatch(AppCommand::AudioOutputPageUp), DeviceKind::Input => ctx.app.audio.input_list.page_up(), } } } KeyCode::PageDown => { if ctx.app.audio.section == EngineSection::Devices { match ctx.app.audio.device_kind { DeviceKind::Output => { let count = ctx.app.audio.output_devices.len(); ctx.dispatch(AppCommand::AudioOutputPageDown(count)); } DeviceKind::Input => { let count = ctx.app.audio.input_devices.len(); ctx.dispatch(AppCommand::AudioInputPageDown(count)); } } } } KeyCode::Enter => { if ctx.app.audio.section == EngineSection::Devices { match ctx.app.audio.device_kind { DeviceKind::Output => { let cursor = ctx.app.audio.output_list.cursor; if cursor < ctx.app.audio.output_devices.len() { let name = ctx.app.audio.output_devices[cursor].name.clone(); ctx.dispatch(AppCommand::SetOutputDevice(name)); ctx.app.save_settings(ctx.link); } } DeviceKind::Input => { let cursor = ctx.app.audio.input_list.cursor; if cursor < ctx.app.audio.input_devices.len() { let name = ctx.app.audio.input_devices[cursor].name.clone(); ctx.dispatch(AppCommand::SetInputDevice(name)); ctx.app.save_settings(ctx.link); } } } } } KeyCode::Left => match ctx.app.audio.section { EngineSection::Devices => { ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Output)); } EngineSection::Settings => { match ctx.app.audio.setting_kind { SettingKind::Channels => ctx.dispatch(AppCommand::AdjustAudioSetting { setting: SettingKind::Channels, delta: -1, }), SettingKind::BufferSize => ctx.dispatch(AppCommand::AdjustAudioSetting { setting: SettingKind::BufferSize, delta: -64, }), SettingKind::Polyphony => ctx.dispatch(AppCommand::AdjustAudioSetting { setting: SettingKind::Polyphony, delta: -1, }), SettingKind::Nudge => { let prev = ctx.nudge_us.load(Ordering::Relaxed); ctx.nudge_us .store((prev - 1000).max(-100_000), Ordering::Relaxed); } } ctx.app.save_settings(ctx.link); } EngineSection::Samples => {} }, KeyCode::Right => match ctx.app.audio.section { EngineSection::Devices => { ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Input)); } EngineSection::Settings => { match ctx.app.audio.setting_kind { SettingKind::Channels => ctx.dispatch(AppCommand::AdjustAudioSetting { setting: SettingKind::Channels, delta: 1, }), SettingKind::BufferSize => ctx.dispatch(AppCommand::AdjustAudioSetting { setting: SettingKind::BufferSize, delta: 64, }), SettingKind::Polyphony => ctx.dispatch(AppCommand::AdjustAudioSetting { setting: SettingKind::Polyphony, delta: 1, }), SettingKind::Nudge => { let prev = ctx.nudge_us.load(Ordering::Relaxed); ctx.nudge_us .store((prev + 1000).min(100_000), Ordering::Relaxed); } } ctx.app.save_settings(ctx.link); } EngineSection::Samples => {} }, KeyCode::Char('R') => ctx.dispatch(AppCommand::AudioTriggerRestart), KeyCode::Char('A') => { use crate::state::file_browser::FileBrowserState; let state = FileBrowserState::new_load(String::new()); ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(state))); } KeyCode::Char('D') => { if ctx.app.audio.section == EngineSection::Samples { ctx.dispatch(AppCommand::RemoveLastSamplePath); } else { ctx.dispatch(AppCommand::AudioRefreshDevices); let out_count = ctx.app.audio.output_devices.len(); let in_count = ctx.app.audio.input_devices.len(); ctx.dispatch(AppCommand::SetStatus(format!( "Found {out_count} output, {in_count} input devices" ))); } } KeyCode::Char('h') => { let _ = ctx.audio_tx.load().send(AudioCommand::Hush); let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll); } KeyCode::Char('p') => { let _ = ctx.audio_tx.load().send(AudioCommand::Panic); let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll); } KeyCode::Char('r') => ctx.dispatch(AppCommand::ResetPeakVoices), KeyCode::Char('t') => { let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate { cmd: "/sound/sine/dur/0.5/decay/0.2".into(), time: None, }); } KeyCode::Char('?') => { ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); } _ => {} } InputResult::Continue } fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match key.code { KeyCode::Char('q') => { ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { selected: false, })); } KeyCode::Down | KeyCode::Tab => ctx.dispatch(AppCommand::OptionsNextFocus), KeyCode::Up | KeyCode::BackTab => ctx.dispatch(AppCommand::OptionsPrevFocus), KeyCode::Left | KeyCode::Right => { match ctx.app.options.focus { OptionsFocus::ColorScheme => { let new_scheme = if key.code == KeyCode::Left { ctx.app.ui.color_scheme.prev() } else { ctx.app.ui.color_scheme.next() }; ctx.dispatch(AppCommand::SetColorScheme(new_scheme)); } OptionsFocus::HueRotation => { let delta = if key.code == KeyCode::Left { -5.0 } else { 5.0 }; let new_rotation = (ctx.app.ui.hue_rotation + delta).rem_euclid(360.0); ctx.dispatch(AppCommand::SetHueRotation(new_rotation)); } OptionsFocus::RefreshRate => ctx.dispatch(AppCommand::ToggleRefreshRate), OptionsFocus::RuntimeHighlight => { ctx.dispatch(AppCommand::ToggleRuntimeHighlight); } OptionsFocus::ShowScope => { ctx.dispatch(AppCommand::ToggleScope); } OptionsFocus::ShowSpectrum => { ctx.dispatch(AppCommand::ToggleSpectrum); } OptionsFocus::ShowCompletion => { ctx.dispatch(AppCommand::ToggleCompletion); } OptionsFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()), OptionsFocus::StartStopSync => ctx .link .set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()), OptionsFocus::Quantum => { let delta = if key.code == KeyCode::Left { -1.0 } else { 1.0 }; ctx.link.set_quantum(ctx.link.quantum() + delta); } OptionsFocus::MidiOutput0 | OptionsFocus::MidiOutput1 | OptionsFocus::MidiOutput2 | OptionsFocus::MidiOutput3 => { let slot = match ctx.app.options.focus { OptionsFocus::MidiOutput0 => 0, OptionsFocus::MidiOutput1 => 1, OptionsFocus::MidiOutput2 => 2, OptionsFocus::MidiOutput3 => 3, _ => 0, }; let all_devices = crate::midi::list_midi_outputs(); let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices .iter() .enumerate() .filter(|(idx, _)| { ctx.app.midi.selected_outputs[slot] == Some(*idx) || !ctx .app .midi .selected_outputs .iter() .enumerate() .any(|(s, sel)| s != slot && *sel == Some(*idx)) }) .collect(); let total_options = available.len() + 1; let current_pos = ctx.app.midi.selected_outputs[slot] .and_then(|idx| available.iter().position(|(i, _)| *i == idx)) .map(|p| p + 1) .unwrap_or(0); let new_pos = if key.code == KeyCode::Left { if current_pos == 0 { total_options - 1 } else { current_pos - 1 } } else { (current_pos + 1) % total_options }; if new_pos == 0 { ctx.app.midi.disconnect_output(slot); ctx.dispatch(AppCommand::SetStatus(format!( "MIDI output {slot}: disconnected" ))); } else { let (device_idx, device) = available[new_pos - 1]; if ctx.app.midi.connect_output(slot, device_idx).is_ok() { ctx.dispatch(AppCommand::SetStatus(format!( "MIDI output {}: {}", slot, device.name ))); } } } OptionsFocus::MidiInput0 | OptionsFocus::MidiInput1 | OptionsFocus::MidiInput2 | OptionsFocus::MidiInput3 => { let slot = match ctx.app.options.focus { OptionsFocus::MidiInput0 => 0, OptionsFocus::MidiInput1 => 1, OptionsFocus::MidiInput2 => 2, OptionsFocus::MidiInput3 => 3, _ => 0, }; let all_devices = crate::midi::list_midi_inputs(); let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices .iter() .enumerate() .filter(|(idx, _)| { ctx.app.midi.selected_inputs[slot] == Some(*idx) || !ctx .app .midi .selected_inputs .iter() .enumerate() .any(|(s, sel)| s != slot && *sel == Some(*idx)) }) .collect(); let total_options = available.len() + 1; let current_pos = ctx.app.midi.selected_inputs[slot] .and_then(|idx| available.iter().position(|(i, _)| *i == idx)) .map(|p| p + 1) .unwrap_or(0); let new_pos = if key.code == KeyCode::Left { if current_pos == 0 { total_options - 1 } else { current_pos - 1 } } else { (current_pos + 1) % total_options }; if new_pos == 0 { ctx.app.midi.disconnect_input(slot); ctx.dispatch(AppCommand::SetStatus(format!( "MIDI input {slot}: disconnected" ))); } else { let (device_idx, device) = available[new_pos - 1]; if ctx.app.midi.connect_input(slot, device_idx).is_ok() { ctx.dispatch(AppCommand::SetStatus(format!( "MIDI input {}: {}", slot, device.name ))); } } } } ctx.app.save_settings(ctx.link); } KeyCode::Char(' ') => { ctx.dispatch(AppCommand::TogglePlaying); ctx.playing .store(ctx.app.playback.playing, Ordering::Relaxed); } KeyCode::Char('?') => { ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); } _ => {} } InputResult::Continue } fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { use crate::state::HelpFocus; let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); if ctx.app.ui.help_search_active { match key.code { KeyCode::Esc => ctx.dispatch(AppCommand::HelpClearSearch), KeyCode::Enter => ctx.dispatch(AppCommand::HelpSearchConfirm), KeyCode::Backspace => ctx.dispatch(AppCommand::HelpSearchBackspace), KeyCode::Char(c) if !ctrl => ctx.dispatch(AppCommand::HelpSearchInput(c)), _ => {} } return InputResult::Continue; } match key.code { KeyCode::Char('/') | KeyCode::Char('f') if key.code == KeyCode::Char('/') || ctrl => { ctx.dispatch(AppCommand::HelpActivateSearch); } KeyCode::Esc if !ctx.app.ui.help_search_query.is_empty() => { ctx.dispatch(AppCommand::HelpClearSearch); } KeyCode::Tab => ctx.dispatch(AppCommand::HelpToggleFocus), KeyCode::Char('j') | KeyCode::Down if ctrl => { ctx.dispatch(AppCommand::HelpNextTopic(5)); } KeyCode::Char('k') | KeyCode::Up if ctrl => { ctx.dispatch(AppCommand::HelpPrevTopic(5)); } KeyCode::Char('j') | KeyCode::Down => match ctx.app.ui.help_focus { HelpFocus::Topics => ctx.dispatch(AppCommand::HelpNextTopic(1)), HelpFocus::Content => ctx.dispatch(AppCommand::HelpScrollDown(1)), }, KeyCode::Char('k') | KeyCode::Up => match ctx.app.ui.help_focus { HelpFocus::Topics => ctx.dispatch(AppCommand::HelpPrevTopic(1)), HelpFocus::Content => ctx.dispatch(AppCommand::HelpScrollUp(1)), }, KeyCode::PageDown => ctx.dispatch(AppCommand::HelpScrollDown(10)), KeyCode::PageUp => ctx.dispatch(AppCommand::HelpScrollUp(10)), KeyCode::Char('q') => { ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { selected: false, })); } KeyCode::Char('?') => { ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); } _ => {} } InputResult::Continue } fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { use crate::state::DictFocus; let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); // Handle search input mode if ctx.app.ui.dict_search_active { match key.code { KeyCode::Esc => ctx.dispatch(AppCommand::DictClearSearch), KeyCode::Enter => ctx.dispatch(AppCommand::DictSearchConfirm), KeyCode::Backspace => ctx.dispatch(AppCommand::DictSearchBackspace), KeyCode::Char(c) if !ctrl => ctx.dispatch(AppCommand::DictSearchInput(c)), _ => {} } return InputResult::Continue; } match key.code { KeyCode::Char('/') | KeyCode::Char('f') if key.code == KeyCode::Char('/') || ctrl => { ctx.dispatch(AppCommand::DictActivateSearch); } KeyCode::Esc if !ctx.app.ui.dict_search_query.is_empty() => { ctx.dispatch(AppCommand::DictClearSearch); } KeyCode::Tab => ctx.dispatch(AppCommand::DictToggleFocus), KeyCode::Char('j') | KeyCode::Down => match ctx.app.ui.dict_focus { DictFocus::Categories => ctx.dispatch(AppCommand::DictNextCategory), DictFocus::Words => ctx.dispatch(AppCommand::DictScrollDown(1)), }, KeyCode::Char('k') | KeyCode::Up => match ctx.app.ui.dict_focus { DictFocus::Categories => ctx.dispatch(AppCommand::DictPrevCategory), DictFocus::Words => ctx.dispatch(AppCommand::DictScrollUp(1)), }, KeyCode::PageDown => ctx.dispatch(AppCommand::DictScrollDown(10)), KeyCode::PageUp => ctx.dispatch(AppCommand::DictScrollUp(10)), KeyCode::Char('q') => { ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { selected: false, })); } KeyCode::Char('?') => { ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); } _ => {} } InputResult::Continue } fn load_project_samples(ctx: &mut InputContext) { let paths = ctx.app.project_state.project.sample_paths.clone(); if paths.is_empty() { return; } let mut total_count = 0; for path in &paths { if path.is_dir() { let index = doux::sampling::scan_samples_dir(path); let count = index.len(); total_count += count; let _ = ctx.audio_tx.load().send(AudioCommand::LoadSamples(index)); } } ctx.app.audio.config.sample_paths = paths; ctx.app.audio.config.sample_count = total_count; if total_count > 0 { ctx.dispatch(AppCommand::SetStatus(format!( "Loaded {total_count} samples from project" ))); } }