Files
Cagire/src/input.rs
2026-01-28 17:39:41 +01:00

1304 lines
51 KiB
Rust

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<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_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::<usize>() {
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::<f64>() {
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<usize> = 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"
)));
}
}