//! Keyboard and mouse input handling — dispatches events to page-specific or modal handlers. pub(crate) mod engine_page; mod help_page; mod main_page; mod modal; mod mouse; pub(crate) mod options_page; mod panel; mod patterns_page; use arc_swap::ArcSwap; use crossbeam_channel::Sender; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent}; use ratatui::layout::Rect; use std::sync::atomic::{AtomicBool, AtomicI64}; 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::page::Page; use crate::state::{MinimapMode, Modal, PanelFocus}; 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_mouse(ctx: &mut InputContext, mouse: MouseEvent, term: Rect) { mouse::handle_mouse(ctx, mouse, term); } 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 !(matches!(ctx.app.ui.minimap, MinimapMode::Hidden) || ctrl && is_arrow) { ctx.dispatch(AppCommand::ClearMinimap); } if ctx.app.ui.show_title { ctx.dispatch(AppCommand::HideTitle); if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) { return InputResult::Continue; } } ctx.dispatch(AppCommand::ClearStatus); if matches!(ctx.app.ui.modal, Modal::None) { handle_normal_input(ctx, key) } else { modal::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_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 panel::handle_panel_input(ctx, key); } if ctrl { let minimap_timed = MinimapMode::Timed(Instant::now() + Duration::from_millis(250)); match key.code { KeyCode::Left => { ctx.app.ui.minimap = minimap_timed; ctx.dispatch(AppCommand::PageLeft); return InputResult::Continue; } KeyCode::Right => { ctx.app.ui.minimap = minimap_timed; ctx.dispatch(AppCommand::PageRight); return InputResult::Continue; } KeyCode::Up => { ctx.app.ui.minimap = minimap_timed; ctx.dispatch(AppCommand::PageUp); return InputResult::Continue; } KeyCode::Down => { ctx.app.ui.minimap = minimap_timed; ctx.dispatch(AppCommand::PageDown); return InputResult::Continue; } _ => {} } } if let Some(page) = match key.code { KeyCode::F(1) => Some(Page::Dict), KeyCode::F(2) => Some(Page::Patterns), KeyCode::F(3) => Some(Page::Options), KeyCode::F(4) => Some(Page::Help), KeyCode::F(5) => Some(Page::Main), KeyCode::F(6) => Some(Page::Engine), _ => None, } { ctx.app.ui.minimap = MinimapMode::Timed(Instant::now() + Duration::from_millis(250)); ctx.dispatch(AppCommand::GoToPage(page)); return InputResult::Continue; } match ctx.app.page { Page::Main => main_page::handle_main_page(ctx, key, ctrl), Page::Patterns => patterns_page::handle_patterns_page(ctx, key), Page::Engine => engine_page::handle_engine_page(ctx, key), Page::Options => options_page::handle_options_page(ctx, key), Page::Help => help_page::handle_help_page(ctx, key), Page::Dict => help_page::handle_dict_page(ctx, key), } } fn open_save(ctx: &mut InputContext) { 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(Box::new(state)))); } fn open_load(ctx: &mut InputContext) { 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(Box::new(state)))); } 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; let mut all_preload_entries = Vec::new(); for path in &paths { if path.is_dir() { let index = doux::sampling::scan_samples_dir(path); let count = index.len(); total_count += count; for e in &index { all_preload_entries.push((e.name.clone(), e.path.clone())); } 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 { 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(all_preload_entries, sr, ®istry); }) .expect("failed to spawn preload thread"); } ctx.dispatch(AppCommand::SetStatus(format!( "Loaded {total_count} samples from project" ))); } }