use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; use super::{InputContext, InputResult}; use crate::commands::AppCommand; use crate::engine::SeqCommand; use crate::model::{FollowUp, PatternSpeed}; use crate::state::{ ConfirmAction, EditorTarget, EuclideanField, Modal, PatternField, PatternPropsField, RenameTarget, ScriptField, }; pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match &mut ctx.app.ui.modal { Modal::Confirm { action, selected } => { let confirmed = *selected; match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { let action = action.clone(); return execute_confirm(ctx, &action); } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { ctx.dispatch(AppCommand::CloseModal); } KeyCode::Left | KeyCode::Right => { if let Modal::Confirm { selected, .. } = &mut ctx.app.ui.modal { *selected = !*selected; } } KeyCode::Enter => { if confirmed { let action = action.clone(); return execute_confirm(ctx, &action); } ctx.dispatch(AppCommand::CloseModal); } _ => {} } } Modal::FileBrowser(state) => match key.code { KeyCode::Enter => { use crate::state::file_browser::FileBrowserMode; let is_save = matches!(state.mode, FileBrowserMode::Save); if let Some(path) = state.confirm() { ctx.dispatch(AppCommand::CloseModal); if is_save { ctx.dispatch(AppCommand::Save(path)); } else { let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll); let _ = ctx.seq_cmd_tx.send(SeqCommand::ResetScriptState); ctx.dispatch(AppCommand::Load(path)); super::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::Rename { target, name } => { match key.code { KeyCode::Enter => { let new_name = if name.trim().is_empty() { None } else { Some(name.clone()) }; let target = target.clone(); ctx.dispatch(rename_command(&target, new_name)); ctx.dispatch(AppCommand::CloseModal); } KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), KeyCode::Backspace => { if let Modal::Rename { name, .. } = &mut ctx.app.ui.modal { name.pop(); } } KeyCode::Char(c) => { if let Modal::Rename { name, .. } = &mut ctx.app.ui.modal { 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::SetScript { field, input } => match key.code { KeyCode::Enter => { let field = *field; match field { ScriptField::Length => { if let Ok(len) = input.parse::() { ctx.dispatch(AppCommand::SetScriptLength(len)); let new_len = ctx.app.project_state.project.script_length; ctx.dispatch(AppCommand::SetStatus(format!( "Script length set to {new_len}" ))); } else { ctx.dispatch(AppCommand::SetStatus("Invalid length".to_string())); } } ScriptField::Speed => { if let Some(speed) = PatternSpeed::from_label(input) { ctx.dispatch(AppCommand::SetScriptSpeed(speed)); ctx.dispatch(AppCommand::SetStatus(format!( "Script 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::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::() { 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 preload_entries: Vec<(String, std::path::PathBuf)> = index .iter() .map(|e| (e.name.clone(), e.path.clone())) .collect(); let _ = ctx.audio_tx.load().send(crate::engine::AudioCommand::LoadSamples(index)); if let Some(sf2_path) = doux::soundfont::find_sf2_file(&path) { let _ = ctx.audio_tx.load().send(crate::engine::AudioCommand::LoadSoundfont(sf2_path)); } ctx.app.audio.add_sample_path(path, count); if let Some(registry) = ctx.app.audio.sample_registry.clone() { let sr = ctx.app.audio.config.sample_rate; std::thread::Builder::new() .name("sample-preload".into()) .spawn(move || { crate::engine::preload_sample_heads(preload_entries, sr, ®istry); }) .expect("failed to spawn preload thread"); } ctx.app.save_settings(ctx.link); 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; } if editor.sample_finder_active() { match key.code { KeyCode::Esc => editor.dismiss_sample_finder(), KeyCode::Tab | KeyCode::Enter => editor.accept_sample_finder(), KeyCode::Backspace => editor.sample_finder_backspace(), KeyCode::Char('n') if ctrl => editor.sample_finder_next(), KeyCode::Char('p') if ctrl => editor.sample_finder_prev(), KeyCode::Char(c) if !ctrl => editor.sample_finder_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 { match ctx.app.editor_ctx.target { EditorTarget::Step => { ctx.dispatch(AppCommand::SaveEditorToStep); ctx.dispatch(AppCommand::CompileCurrentStep); } EditorTarget::Prelude => { ctx.dispatch(AppCommand::SavePrelude); ctx.dispatch(AppCommand::EvaluatePrelude); ctx.dispatch(AppCommand::ClosePreludeEditor); } } ctx.dispatch(AppCommand::CloseModal); } } KeyCode::Char('e') if ctrl => match ctx.app.editor_ctx.target { EditorTarget::Step => { ctx.dispatch(AppCommand::SaveEditorToStep); ctx.dispatch(AppCommand::CompileCurrentStep); } EditorTarget::Prelude => { ctx.dispatch(AppCommand::SavePrelude); ctx.dispatch(AppCommand::EvaluatePrelude); } }, KeyCode::Char('b') if ctrl => { editor.activate_sample_finder(); } KeyCode::Char('f') if ctrl => { editor.activate_search(); } KeyCode::Char('n') if ctrl => { if editor.completion_active() { editor.completion_next(); } else if editor.sample_finder_active() { editor.sample_finder_next(); } else { editor.search_next(); } } KeyCode::Char('p') if ctrl => { if editor.completion_active() { editor.completion_prev(); } else if editor.sample_finder_active() { editor.sample_finder_prev(); } else { editor.search_prev(); } } 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 => { ctx.app.editor_ctx.editor.copy(); let text = ctx.app.editor_ctx.editor.yank_text(); if let Ok(mut clip) = arboard::Clipboard::new() { let _ = clip.set_text(text); } } KeyCode::Char('x') if ctrl => { ctx.app.editor_ctx.editor.cut(); let text = ctx.app.editor_ctx.editor.yank_text(); if let Ok(mut clip) = arboard::Clipboard::new() { let _ = clip.set_text(text); } } KeyCode::Char('v') if ctrl => { if let Ok(mut clip) = arboard::Clipboard::new() { if let Ok(text) = clip.get_text() { ctx.app.editor_ctx.editor.set_yank_text(text); } } ctx.app.editor_ctx.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::PatternProps { bank, pattern, field, name, description, length, speed, quantization, sync_mode, follow_up, } => { let (bank, pattern) = (*bank, *pattern); let is_chain = matches!(follow_up, FollowUp::Chain { .. }); match key.code { KeyCode::Up => *field = field.prev(is_chain), KeyCode::Down | KeyCode::Tab => *field = field.next(is_chain), KeyCode::Left => match field { PatternPropsField::Speed => *speed = speed.prev(), PatternPropsField::Quantization => *quantization = quantization.prev(), PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(), PatternPropsField::FollowUp => *follow_up = follow_up.prev_mode(), PatternPropsField::ChainBank => { if let FollowUp::Chain { bank: b, .. } = follow_up { *b = b.saturating_sub(1); } } PatternPropsField::ChainPattern => { if let FollowUp::Chain { pattern: p, .. } = follow_up { *p = p.saturating_sub(1); } } _ => {} }, KeyCode::Right => match field { PatternPropsField::Speed => *speed = speed.next(), PatternPropsField::Quantization => *quantization = quantization.next(), PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(), PatternPropsField::FollowUp => *follow_up = follow_up.next_mode(), PatternPropsField::ChainBank => { if let FollowUp::Chain { bank: b, .. } = follow_up { *b = (*b + 1).min(31); } } PatternPropsField::ChainPattern => { if let FollowUp::Chain { pattern: p, .. } = follow_up { *p = (*p + 1).min(31); } } _ => {} }, KeyCode::Char(c) => match field { PatternPropsField::Name => name.push(c), PatternPropsField::Description => description.push(c), PatternPropsField::Length if c.is_ascii_digit() => length.push(c), _ => {} }, KeyCode::Backspace => match field { PatternPropsField::Name => { name.pop(); } PatternPropsField::Description => { description.pop(); } PatternPropsField::Length => { length.pop(); } _ => {} }, KeyCode::Enter => { let name_val = if name.is_empty() { None } else { Some(name.clone()) }; let desc_val = if description.is_empty() { None } else { Some(description.clone()) }; let length_val = length.parse().ok(); let speed_val = *speed; let quant_val = *quantization; let sync_val = *sync_mode; let follow_up_val = *follow_up; ctx.dispatch(AppCommand::StagePatternProps { bank, pattern, name: name_val, description: desc_val, length: length_val, speed: speed_val, quantization: quant_val, sync_mode: sync_val, follow_up: follow_up_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, ctx.app.plugin_mode).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(1024).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::Onboarding { .. } => { let pages = crate::model::onboarding::for_page(ctx.app.page); let page_count = pages.len(); match key.code { KeyCode::Right | KeyCode::Char('l') if page_count > 1 => { if let Modal::Onboarding { page } = &mut ctx.app.ui.modal { if *page + 1 < page_count { *page += 1; } } } KeyCode::Left | KeyCode::Char('h') if page_count > 1 => { if let Modal::Onboarding { page } = &mut ctx.app.ui.modal { *page = page.saturating_sub(1); } } KeyCode::Char('?') | KeyCode::F(1) => { if let Some(topic) = ctx.app.page.help_topic_index() { ctx.dispatch(AppCommand::GoToHelpTopic(topic)); } else { ctx.dispatch(AppCommand::CloseModal); } } KeyCode::Enter => { ctx.dispatch(AppCommand::DismissOnboarding); ctx.dispatch(AppCommand::CloseModal); ctx.app.save_settings(ctx.link); } _ => ctx.dispatch(AppCommand::CloseModal), } } Modal::None => unreachable!(), } InputResult::Continue } fn execute_confirm(ctx: &mut InputContext, action: &ConfirmAction) -> InputResult { match action { ConfirmAction::Quit => return InputResult::Quit, ConfirmAction::DeleteStep { bank, pattern, step } => { ctx.dispatch(AppCommand::DeleteStep { bank: *bank, pattern: *pattern, step: *step }); } ConfirmAction::DeleteSteps { bank, pattern, steps } => { ctx.dispatch(AppCommand::DeleteSteps { bank: *bank, pattern: *pattern, steps: steps.clone() }); } ConfirmAction::ResetPattern { bank, pattern } => { ctx.dispatch(AppCommand::ResetPattern { bank: *bank, pattern: *pattern }); } ConfirmAction::ResetBank { bank } => { ctx.dispatch(AppCommand::ResetBank { bank: *bank }); } ConfirmAction::ResetPatterns { bank, patterns } => { ctx.dispatch(AppCommand::ResetPatterns { bank: *bank, patterns: patterns.clone() }); } ConfirmAction::ResetBanks { banks } => { ctx.dispatch(AppCommand::ResetBanks { banks: banks.clone() }); } ConfirmAction::ImportBank { bank } => { ctx.dispatch(AppCommand::ImportBank { bank: *bank }); } } ctx.dispatch(AppCommand::CloseModal); InputResult::Continue } fn rename_command(target: &RenameTarget, name: Option) -> AppCommand { match target { RenameTarget::Bank { bank } => AppCommand::RenameBank { bank: *bank, name }, RenameTarget::Pattern { bank, pattern } => AppCommand::RenamePattern { bank: *bank, pattern: *pattern, name, }, RenameTarget::Step { bank, pattern, step } => AppCommand::RenameStep { bank: *bank, pattern: *pattern, step: *step, name, }, RenameTarget::DescribePattern { bank, pattern } => AppCommand::DescribePattern { bank: *bank, pattern: *pattern, description: name, }, } }