236 lines
7.4 KiB
Rust
236 lines
7.4 KiB
Rust
//! 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;
|
|
mod script_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<AtomicBool>,
|
|
pub audio_tx: &'a ArcSwap<Sender<AudioCommand>>,
|
|
pub seq_cmd_tx: &'a Sender<SeqCommand>,
|
|
pub nudge_us: &'a Arc<AtomicI64>,
|
|
}
|
|
|
|
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,
|
|
_ if ctx.app.page == Page::Script && ctx.app.script_editor.focused => 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),
|
|
KeyCode::F(7) => Some(Page::Script),
|
|
_ => 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),
|
|
Page::Script => script_page::handle_script_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;
|
|
|
|
for path in &ctx.app.audio.config.sample_paths {
|
|
if let Some(sf2_path) = doux::soundfont::find_sf2_file(path) {
|
|
let _ = ctx.audio_tx.load().send(AudioCommand::LoadSoundfont(sf2_path));
|
|
break;
|
|
}
|
|
}
|
|
|
|
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"
|
|
)));
|
|
}
|
|
}
|