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::{ DeviceKind, EngineSection, 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.app.ui.minimap_until = None; } if ctx.app.ui.show_title { ctx.app.ui.show_title = false; 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.app.live_keys.flip_fill(); 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 => { 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::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::loader::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 => { editor.search_next(); } KeyCode::Char('p') if ctrl => { editor.search_prev(); } 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)); } } } 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::SetPatternProps { 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::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)); } _ => state.toggle_expand(), } } } KeyCode::Left => state.collapse_at_cursor(), KeyCode::Char('/') => state.activate_search(), KeyCode::Esc | KeyCode::Tab => { ctx.app.panel.visible = false; ctx.app.panel.focus = PanelFocus::Main; } _ => {} } } 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.app.editor_ctx.selection_anchor = Some(ctx.app.editor_ctx.step); } ctx.dispatch(AppCommand::PrevStep); } KeyCode::Right if shift && !ctrl => { if ctx.app.editor_ctx.selection_anchor.is_none() { ctx.app.editor_ctx.selection_anchor = Some(ctx.app.editor_ctx.step); } ctx.dispatch(AppCommand::NextStep); } KeyCode::Up if shift && !ctrl => { if ctx.app.editor_ctx.selection_anchor.is_none() { ctx.app.editor_ctx.selection_anchor = Some(ctx.app.editor_ctx.step); } ctx.dispatch(AppCommand::StepUp); } KeyCode::Down if shift && !ctrl => { if ctx.app.editor_ctx.selection_anchor.is_none() { ctx.app.editor_ctx.selection_anchor = Some(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('?') => { ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); } _ => {} } 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.dispatch(AppCommand::ClearStagedChanges); } else { ctx.dispatch(AppCommand::PatternsBack); } } KeyCode::Enter => ctx.dispatch(AppCommand::PatternsEnter), KeyCode::Char(' ') => ctx.dispatch(AppCommand::PatternsTogglePlay), KeyCode::Char('c') if !ctrl => ctx.dispatch(AppCommand::CommitStagedChanges), 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('?') => { 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.app.audio.next_section(), KeyCode::BackTab => ctx.app.audio.prev_section(), KeyCode::Up => match ctx.app.audio.section { EngineSection::Devices => match ctx.app.audio.device_kind { DeviceKind::Output => ctx.app.audio.output_list.move_up(), DeviceKind::Input => ctx.app.audio.input_list.move_up(), }, EngineSection::Settings => { ctx.app.audio.setting_kind = ctx.app.audio.setting_kind.prev(); } 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.app.audio.output_list.move_down(count); } DeviceKind::Input => { let count = ctx.app.audio.input_devices.len(); ctx.app.audio.input_list.move_down(count); } }, EngineSection::Settings => { ctx.app.audio.setting_kind = ctx.app.audio.setting_kind.next(); } EngineSection::Samples => {} }, KeyCode::PageUp => { if ctx.app.audio.section == EngineSection::Devices { match ctx.app.audio.device_kind { DeviceKind::Output => ctx.app.audio.output_list.page_up(), 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.app.audio.output_list.page_down(count); } DeviceKind::Input => { let count = ctx.app.audio.input_devices.len(); ctx.app.audio.input_list.page_down(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() { ctx.app.audio.config.output_device = Some(ctx.app.audio.output_devices[cursor].name.clone()); ctx.app.save_settings(ctx.link); } } DeviceKind::Input => { let cursor = ctx.app.audio.input_list.cursor; if cursor < ctx.app.audio.input_devices.len() { ctx.app.audio.config.input_device = Some(ctx.app.audio.input_devices[cursor].name.clone()); ctx.app.save_settings(ctx.link); } } } } } KeyCode::Left => match ctx.app.audio.section { EngineSection::Devices => { ctx.app.audio.device_kind = DeviceKind::Output; } EngineSection::Settings => { match ctx.app.audio.setting_kind { SettingKind::Channels => ctx.app.audio.adjust_channels(-1), SettingKind::BufferSize => ctx.app.audio.adjust_buffer_size(-64), SettingKind::Polyphony => ctx.app.audio.adjust_max_voices(-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.app.audio.device_kind = DeviceKind::Input; } EngineSection::Settings => { match ctx.app.audio.setting_kind { SettingKind::Channels => ctx.app.audio.adjust_channels(1), SettingKind::BufferSize => ctx.app.audio.adjust_buffer_size(64), SettingKind::Polyphony => ctx.app.audio.adjust_max_voices(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.app.audio.trigger_restart(), 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.app.audio.remove_last_sample_path(); } else { ctx.app.audio.refresh_devices(); 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.app.metrics.peak_voices = 0, KeyCode::Char('t') => { let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate( "/sound/sine/dur/0.5/decay/0.2".into(), )); } 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.app.options.next_focus(), KeyCode::Up | KeyCode::BackTab => ctx.app.options.prev_focus(), KeyCode::Left | KeyCode::Right => { match ctx.app.options.focus { OptionsFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(), OptionsFocus::RuntimeHighlight => { ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight } OptionsFocus::ShowScope => { ctx.app.audio.config.show_scope = !ctx.app.audio.config.show_scope } OptionsFocus::ShowSpectrum => { ctx.app.audio.config.show_spectrum = !ctx.app.audio.config.show_spectrum } OptionsFocus::ShowCompletion => { ctx.app.ui.show_completion = !ctx.app.ui.show_completion } OptionsFocus::FlashBrightness => { let delta = if key.code == KeyCode::Left { -0.1 } else { 0.1 }; ctx.app.ui.flash_brightness = (ctx.app.ui.flash_brightness + delta).clamp(0.0, 1.0); } 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); } } 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 { 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) => ctx.dispatch(AppCommand::HelpSearchInput(c)), _ => {} } return InputResult::Continue; } match key.code { KeyCode::Char('/') => ctx.dispatch(AppCommand::HelpActivateSearch), KeyCode::Esc if !ctx.app.ui.help_search_query.is_empty() => { ctx.dispatch(AppCommand::HelpClearSearch); } KeyCode::Char('j') | KeyCode::Down => ctx.dispatch(AppCommand::HelpScrollDown(1)), KeyCode::Char('k') | KeyCode::Up => ctx.dispatch(AppCommand::HelpScrollUp(1)), KeyCode::Tab => ctx.dispatch(AppCommand::HelpNextTopic), KeyCode::BackTab => ctx.dispatch(AppCommand::HelpPrevTopic), 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::loader::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" ))); } }