use crossbeam_channel::Sender; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use crate::app::App; use crate::commands::AppCommand; use crate::engine::{AudioCommand, LinkState, SequencerSnapshot}; use crate::model::PatternSpeed; use crate::page::Page; use crate::state::{AudioFocus, Modal, PatternField}; 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 Sender, } 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; } 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::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::SaveAs(path) => match key.code { KeyCode::Enter => { let save_path = PathBuf::from(path.as_str()); ctx.dispatch(AppCommand::CloseModal); ctx.dispatch(AppCommand::Save(save_path)); } KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), KeyCode::Backspace => { path.pop(); } KeyCode::Char(c) => path.push(c), _ => {} }, Modal::LoadFrom(path) => match key.code { KeyCode::Enter => { let load_path = PathBuf::from(path.as_str()); ctx.dispatch(AppCommand::CloseModal); ctx.dispatch(AppCommand::Load(load_path)); load_project_samples(ctx); } KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), KeyCode::Backspace => { path.pop(); } KeyCode::Char(c) => path.push(c), _ => {} }, 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/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x)".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(path) => match key.code { KeyCode::Enter => { let sample_path = PathBuf::from(path.as_str()); if sample_path.is_dir() { let index = doux::loader::scan_samples_dir(&sample_path); let count = index.len(); let _ = ctx.audio_tx.send(AudioCommand::LoadSamples(index)); ctx.app.audio.config.sample_count += count; ctx.app.audio.add_sample_path(sample_path); ctx.dispatch(AppCommand::SetStatus(format!("Added {count} samples"))); } else { ctx.dispatch(AppCommand::SetStatus("Path is not a directory".to_string())); } ctx.dispatch(AppCommand::CloseModal); } KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), KeyCode::Backspace => { path.pop(); } KeyCode::Char(c) => path.push(c), _ => {} }, Modal::Editor => { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { KeyCode::Esc => { 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); } _ => { ctx.app.editor_ctx.text.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::None => unreachable!(), } InputResult::Continue } fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); if ctrl { match key.code { KeyCode::Left => { ctx.dispatch(AppCommand::PageLeft); return InputResult::Continue; } KeyCode::Right => { ctx.dispatch(AppCommand::PageRight); return InputResult::Continue; } KeyCode::Up => { ctx.dispatch(AppCommand::PageUp); return InputResult::Continue; } KeyCode::Down => { 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::Audio => handle_audio_page(ctx, key), Page::Doc => handle_doc_page(ctx, key), } } fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputResult { match key.code { 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 => ctx.dispatch(AppCommand::PrevStep), KeyCode::Right => ctx.dispatch(AppCommand::NextStep), KeyCode::Up => ctx.dispatch(AppCommand::StepUp), KeyCode::Down => ctx.dispatch(AppCommand::StepDown), KeyCode::Enter => ctx.dispatch(AppCommand::OpenModal(Modal::Editor)), KeyCode::Char('t') => ctx.dispatch(AppCommand::ToggleStep), KeyCode::Char('s') => { ctx.dispatch(AppCommand::OpenModal(Modal::SaveAs(String::new()))); } KeyCode::Char('c') if ctrl => ctx.dispatch(AppCommand::CopyStep), KeyCode::Char('v') if ctrl => ctx.dispatch(AppCommand::PasteStep), KeyCode::Char('b') if ctrl => ctx.dispatch(AppCommand::LinkPasteStep), KeyCode::Char('h') if ctrl => ctx.dispatch(AppCommand::HardenStep), KeyCode::Char('l') => { 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(); ctx.dispatch(AppCommand::OpenModal(Modal::LoadFrom(default_dir))); } 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); let step = ctx.app.editor_ctx.step; ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmDeleteStep { bank, pattern, step, selected: false, })); } _ => {} } 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 => ctx.dispatch(AppCommand::PatternsBack), KeyCode::Enter => ctx.dispatch(AppCommand::PatternsEnter), KeyCode::Char(' ') => ctx.dispatch(AppCommand::PatternsTogglePlay), 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, })); } } } _ => {} } InputResult::Continue } fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match key.code { KeyCode::Char('q') => { ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { selected: false, })); } KeyCode::Up | KeyCode::Char('k') => ctx.app.audio.prev_focus(), KeyCode::Down | KeyCode::Char('j') => ctx.app.audio.next_focus(), KeyCode::Left => { match ctx.app.audio.focus { AudioFocus::OutputDevice => ctx.app.audio.prev_output_device(), AudioFocus::InputDevice => ctx.app.audio.prev_input_device(), AudioFocus::Channels => ctx.app.audio.adjust_channels(-1), AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(-64), AudioFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(), AudioFocus::RuntimeHighlight => { ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight } AudioFocus::SamplePaths => ctx.app.audio.remove_last_sample_path(), AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()), AudioFocus::StartStopSync => ctx .link .set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()), AudioFocus::Quantum => ctx.link.set_quantum(ctx.link.quantum() - 1.0), } if ctx.app.audio.focus != AudioFocus::SamplePaths { ctx.app.save_settings(ctx.link); } } KeyCode::Right => { match ctx.app.audio.focus { AudioFocus::OutputDevice => ctx.app.audio.next_output_device(), AudioFocus::InputDevice => ctx.app.audio.next_input_device(), AudioFocus::Channels => ctx.app.audio.adjust_channels(1), AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(64), AudioFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(), AudioFocus::RuntimeHighlight => { ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight } AudioFocus::SamplePaths => {} AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()), AudioFocus::StartStopSync => ctx .link .set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()), AudioFocus::Quantum => ctx.link.set_quantum(ctx.link.quantum() + 1.0), } if ctx.app.audio.focus != AudioFocus::SamplePaths { ctx.app.save_settings(ctx.link); } } KeyCode::Char('R') => ctx.app.audio.trigger_restart(), KeyCode::Char('A') => { ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(String::new()))); } KeyCode::Char('D') => { 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.send(AudioCommand::Hush); } KeyCode::Char('p') => { let _ = ctx.audio_tx.send(AudioCommand::Panic); } KeyCode::Char('r') => ctx.app.metrics.peak_voices = 0, KeyCode::Char('t') => { let _ = ctx .audio_tx .send(AudioCommand::Evaluate("/sound/sine/dur/0.5/decay/0.2".into())); } KeyCode::Char(' ') => { ctx.dispatch(AppCommand::TogglePlaying); ctx.playing .store(ctx.app.playback.playing, Ordering::Relaxed); } _ => {} } InputResult::Continue } fn handle_doc_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match key.code { KeyCode::Char('j') | KeyCode::Down => ctx.dispatch(AppCommand::DocScrollDown(1)), KeyCode::Char('k') | KeyCode::Up => ctx.dispatch(AppCommand::DocScrollUp(1)), KeyCode::Char('h') | KeyCode::Left => ctx.dispatch(AppCommand::DocPrevCategory), KeyCode::Char('l') | KeyCode::Right => ctx.dispatch(AppCommand::DocNextCategory), KeyCode::Tab => ctx.dispatch(AppCommand::DocNextTopic), KeyCode::BackTab => ctx.dispatch(AppCommand::DocPrevTopic), KeyCode::PageDown => ctx.dispatch(AppCommand::DocScrollDown(10)), KeyCode::PageUp => ctx.dispatch(AppCommand::DocScrollUp(10)), KeyCode::Char('q') => { ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { selected: false, })); } _ => {} } 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.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" ))); } }