1746 lines
71 KiB
Rust
1746 lines
71 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::{
|
|
ConfirmAction, CyclicEnum, DeviceKind, EditorTarget, EngineSection, EuclideanField, Modal,
|
|
OptionsFocus, PanelFocus, PatternField, PatternPropsField, RenameTarget, 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.dispatch(AppCommand::ClearMinimap);
|
|
}
|
|
|
|
if ctx.app.ui.show_title {
|
|
ctx.dispatch(AppCommand::HideTitle);
|
|
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.dispatch(AppCommand::ToggleLiveKeysFill);
|
|
true
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
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() });
|
|
}
|
|
}
|
|
ctx.dispatch(AppCommand::CloseModal);
|
|
InputResult::Continue
|
|
}
|
|
|
|
fn rename_command(target: &RenameTarget, name: Option<String>) -> 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,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|
match &mut ctx.app.ui.modal {
|
|
Modal::Confirm { action, selected } => {
|
|
let (action, confirmed) = (action.clone(), *selected);
|
|
match key.code {
|
|
KeyCode::Char('y') | KeyCode::Char('Y') => 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 {
|
|
return execute_confirm(ctx, &action);
|
|
}
|
|
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 => {
|
|
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
|
|
let _ = ctx.seq_cmd_tx.send(SeqCommand::ResetScriptState);
|
|
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::Rename { target, name } => {
|
|
let target = target.clone();
|
|
match key.code {
|
|
KeyCode::Enter => {
|
|
let new_name = if name.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(name.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::<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::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(AudioCommand::LoadSamples(index));
|
|
ctx.app.audio.config.sample_count += count;
|
|
ctx.app.audio.add_sample_path(path);
|
|
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::init::preload_sample_heads(preload_entries, sr, ®istry);
|
|
})
|
|
.expect("failed to spawn preload thread");
|
|
}
|
|
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('s') if ctrl => {
|
|
ctx.dispatch(AppCommand::ToggleEditorStack);
|
|
}
|
|
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 => {
|
|
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));
|
|
}
|
|
}
|
|
|
|
if ctx.app.editor_ctx.show_stack {
|
|
crate::services::stack_preview::update_cache(&ctx.app.editor_ctx);
|
|
}
|
|
}
|
|
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::StagePatternProps {
|
|
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::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::<usize>() {
|
|
*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::<usize>() {
|
|
*target = (val + 1).min(128).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::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::PageUp => {
|
|
for _ in 0..20 {
|
|
state.move_up();
|
|
}
|
|
}
|
|
KeyCode::PageDown => {
|
|
for _ in 0..20 {
|
|
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/1.00/dur/1");
|
|
let _ = ctx
|
|
.audio_tx
|
|
.load()
|
|
.send(AudioCommand::Evaluate { cmd, time: None });
|
|
}
|
|
_ => state.toggle_expand(),
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Left => state.collapse_at_cursor(),
|
|
KeyCode::Char('/') => state.activate_search(),
|
|
KeyCode::Esc => {
|
|
if state.has_filter() {
|
|
state.clear_filter();
|
|
} else {
|
|
ctx.dispatch(AppCommand::ClosePanel);
|
|
}
|
|
}
|
|
KeyCode::Tab => {
|
|
ctx.dispatch(AppCommand::ClosePanel);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
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::Confirm {
|
|
action: ConfirmAction::Quit,
|
|
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.dispatch(AppCommand::SetSelectionAnchor(ctx.app.editor_ctx.step));
|
|
}
|
|
ctx.dispatch(AppCommand::PrevStep);
|
|
}
|
|
KeyCode::Right if shift && !ctrl => {
|
|
if ctx.app.editor_ctx.selection_anchor.is_none() {
|
|
ctx.dispatch(AppCommand::SetSelectionAnchor(ctx.app.editor_ctx.step));
|
|
}
|
|
ctx.dispatch(AppCommand::NextStep);
|
|
}
|
|
KeyCode::Up if shift && !ctrl => {
|
|
if ctx.app.editor_ctx.selection_anchor.is_none() {
|
|
ctx.dispatch(AppCommand::SetSelectionAnchor(ctx.app.editor_ctx.step));
|
|
}
|
|
ctx.dispatch(AppCommand::StepUp);
|
|
}
|
|
KeyCode::Down if shift && !ctrl => {
|
|
if ctx.app.editor_ctx.selection_anchor.is_none() {
|
|
ctx.dispatch(AppCommand::SetSelectionAnchor(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(Box::new(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(Box::new(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::Confirm {
|
|
action: ConfirmAction::DeleteSteps { bank, pattern, steps },
|
|
selected: false,
|
|
}));
|
|
} else {
|
|
let step = ctx.app.editor_ctx.step;
|
|
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
|
|
action: ConfirmAction::DeleteStep { bank, pattern, step },
|
|
selected: false,
|
|
}));
|
|
}
|
|
}
|
|
KeyCode::Char('r') if ctrl => {
|
|
let pattern = ctx.app.current_edit_pattern();
|
|
if let Some(script) = pattern.resolve_script(ctx.app.editor_ctx.step) {
|
|
if !script.trim().is_empty() {
|
|
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('r') => {
|
|
let (bank, pattern, step) = (
|
|
ctx.app.editor_ctx.bank,
|
|
ctx.app.editor_ctx.pattern,
|
|
ctx.app.editor_ctx.step,
|
|
);
|
|
let current_name = ctx
|
|
.app
|
|
.current_edit_pattern()
|
|
.step(step)
|
|
.and_then(|s| s.name.clone())
|
|
.unwrap_or_default();
|
|
ctx.dispatch(AppCommand::OpenModal(Modal::Rename {
|
|
target: RenameTarget::Step { bank, pattern, step },
|
|
name: current_name,
|
|
}));
|
|
}
|
|
KeyCode::Char('o') => {
|
|
ctx.app.audio.config.layout = ctx.app.audio.config.layout.next();
|
|
}
|
|
KeyCode::Char('?') => {
|
|
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
|
}
|
|
KeyCode::Char('e') | KeyCode::Char('E') => {
|
|
let (bank, pattern, step) = (
|
|
ctx.app.editor_ctx.bank,
|
|
ctx.app.editor_ctx.pattern,
|
|
ctx.app.editor_ctx.step,
|
|
);
|
|
let pattern_len = ctx.app.current_edit_pattern().length;
|
|
let default_steps = pattern_len.min(32);
|
|
let default_pulses = (default_steps / 2).max(1).min(default_steps);
|
|
ctx.dispatch(AppCommand::OpenModal(Modal::EuclideanDistribution {
|
|
bank,
|
|
pattern,
|
|
source_step: step,
|
|
field: EuclideanField::Pulses,
|
|
pulses: default_pulses.to_string(),
|
|
steps: default_steps.to_string(),
|
|
rotation: "0".to_string(),
|
|
}));
|
|
}
|
|
KeyCode::Char('m') => {
|
|
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
|
|
ctx.dispatch(AppCommand::StageMute { bank, pattern });
|
|
}
|
|
KeyCode::Char('x') => {
|
|
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
|
|
ctx.dispatch(AppCommand::StageSolo { bank, pattern });
|
|
}
|
|
KeyCode::Char('M') => {
|
|
ctx.dispatch(AppCommand::ClearMutes);
|
|
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
|
}
|
|
KeyCode::Char('X') => {
|
|
ctx.dispatch(AppCommand::ClearSolos);
|
|
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
|
}
|
|
KeyCode::Char('d') => {
|
|
ctx.dispatch(AppCommand::OpenPreludeEditor);
|
|
}
|
|
KeyCode::Char('D') => {
|
|
ctx.dispatch(AppCommand::EvaluatePrelude);
|
|
}
|
|
_ => {}
|
|
}
|
|
InputResult::Continue
|
|
}
|
|
|
|
fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|
use crate::state::PatternsColumn;
|
|
|
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
|
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
|
|
|
match key.code {
|
|
KeyCode::Up if shift => {
|
|
match ctx.app.patterns_nav.column {
|
|
PatternsColumn::Banks => {
|
|
if ctx.app.patterns_nav.bank_anchor.is_none() {
|
|
ctx.app.patterns_nav.bank_anchor = Some(ctx.app.patterns_nav.bank_cursor);
|
|
}
|
|
}
|
|
PatternsColumn::Patterns => {
|
|
if ctx.app.patterns_nav.pattern_anchor.is_none() {
|
|
ctx.app.patterns_nav.pattern_anchor =
|
|
Some(ctx.app.patterns_nav.pattern_cursor);
|
|
}
|
|
}
|
|
}
|
|
ctx.app.patterns_nav.move_up_clamped();
|
|
}
|
|
KeyCode::Down if shift => {
|
|
match ctx.app.patterns_nav.column {
|
|
PatternsColumn::Banks => {
|
|
if ctx.app.patterns_nav.bank_anchor.is_none() {
|
|
ctx.app.patterns_nav.bank_anchor = Some(ctx.app.patterns_nav.bank_cursor);
|
|
}
|
|
}
|
|
PatternsColumn::Patterns => {
|
|
if ctx.app.patterns_nav.pattern_anchor.is_none() {
|
|
ctx.app.patterns_nav.pattern_anchor =
|
|
Some(ctx.app.patterns_nav.pattern_cursor);
|
|
}
|
|
}
|
|
}
|
|
ctx.app.patterns_nav.move_down_clamped();
|
|
}
|
|
KeyCode::Up => {
|
|
ctx.app.patterns_nav.clear_selection();
|
|
ctx.dispatch(AppCommand::PatternsCursorUp);
|
|
}
|
|
KeyCode::Down => {
|
|
ctx.app.patterns_nav.clear_selection();
|
|
ctx.dispatch(AppCommand::PatternsCursorDown);
|
|
}
|
|
KeyCode::Left => ctx.dispatch(AppCommand::PatternsCursorLeft),
|
|
KeyCode::Right => ctx.dispatch(AppCommand::PatternsCursorRight),
|
|
KeyCode::Esc => {
|
|
if ctx.app.patterns_nav.has_selection() {
|
|
ctx.app.patterns_nav.clear_selection();
|
|
} else if !ctx.app.playback.staged_changes.is_empty()
|
|
|| !ctx.app.playback.staged_mute_changes.is_empty()
|
|
|| !ctx.app.playback.staged_prop_changes.is_empty()
|
|
{
|
|
ctx.dispatch(AppCommand::ClearStagedChanges);
|
|
} else {
|
|
ctx.dispatch(AppCommand::PatternsBack);
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
if !ctx.app.patterns_nav.has_selection() {
|
|
ctx.dispatch(AppCommand::PatternsEnter);
|
|
}
|
|
}
|
|
KeyCode::Char('p') => {
|
|
if ctx.app.patterns_nav.column == PatternsColumn::Patterns {
|
|
let bank = ctx.app.patterns_nav.bank_cursor;
|
|
for pattern in ctx.app.patterns_nav.selected_patterns() {
|
|
ctx.app.stage_pattern_toggle(bank, pattern, ctx.snapshot);
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Char(' ') => {
|
|
ctx.dispatch(AppCommand::TogglePlaying);
|
|
ctx.playing
|
|
.store(ctx.app.playback.playing, Ordering::Relaxed);
|
|
}
|
|
KeyCode::Char('c') if !ctrl => {
|
|
let mute_changed = ctx.app.commit_staged_changes();
|
|
if mute_changed {
|
|
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
|
}
|
|
}
|
|
KeyCode::Char('q') => {
|
|
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
|
|
action: ConfirmAction::Quit,
|
|
selected: false,
|
|
}));
|
|
}
|
|
KeyCode::Char('c') if ctrl => {
|
|
let bank = ctx.app.patterns_nav.bank_cursor;
|
|
match ctx.app.patterns_nav.column {
|
|
PatternsColumn::Banks => {
|
|
let banks = ctx.app.patterns_nav.selected_banks();
|
|
if banks.len() > 1 {
|
|
ctx.dispatch(AppCommand::CopyBanks { banks });
|
|
} else {
|
|
ctx.dispatch(AppCommand::CopyBank { bank });
|
|
}
|
|
}
|
|
PatternsColumn::Patterns => {
|
|
let patterns = ctx.app.patterns_nav.selected_patterns();
|
|
if patterns.len() > 1 {
|
|
ctx.dispatch(AppCommand::CopyPatterns { bank, patterns });
|
|
} else {
|
|
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 => {
|
|
if ctx.app.copied_banks.as_ref().is_some_and(|v| v.len() > 1) {
|
|
ctx.dispatch(AppCommand::PasteBanks { start: bank });
|
|
} else {
|
|
ctx.dispatch(AppCommand::PasteBank { bank });
|
|
}
|
|
}
|
|
PatternsColumn::Patterns => {
|
|
let pattern = ctx.app.patterns_nav.pattern_cursor;
|
|
if ctx
|
|
.app
|
|
.copied_patterns
|
|
.as_ref()
|
|
.is_some_and(|v| v.len() > 1)
|
|
{
|
|
ctx.dispatch(AppCommand::PastePatterns {
|
|
bank,
|
|
start: pattern,
|
|
});
|
|
} else {
|
|
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 => {
|
|
let banks = ctx.app.patterns_nav.selected_banks();
|
|
if banks.len() > 1 {
|
|
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
|
|
action: ConfirmAction::ResetBanks { banks },
|
|
selected: false,
|
|
}));
|
|
} else {
|
|
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
|
|
action: ConfirmAction::ResetBank { bank },
|
|
selected: false,
|
|
}));
|
|
}
|
|
}
|
|
PatternsColumn::Patterns => {
|
|
let patterns = ctx.app.patterns_nav.selected_patterns();
|
|
if patterns.len() > 1 {
|
|
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
|
|
action: ConfirmAction::ResetPatterns { bank, patterns },
|
|
selected: false,
|
|
}));
|
|
} else {
|
|
let pattern = ctx.app.patterns_nav.pattern_cursor;
|
|
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
|
|
action: ConfirmAction::ResetPattern { bank, pattern },
|
|
selected: false,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Char('r') => {
|
|
if !ctx.app.patterns_nav.has_selection() {
|
|
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::Rename {
|
|
target: RenameTarget::Bank { 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::Rename {
|
|
target: RenameTarget::Pattern { bank, pattern },
|
|
name: current_name,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Char('e') if !ctrl => {
|
|
if ctx.app.patterns_nav.column == PatternsColumn::Patterns
|
|
&& !ctx.app.patterns_nav.has_selection()
|
|
{
|
|
let bank = ctx.app.patterns_nav.bank_cursor;
|
|
let pattern = ctx.app.patterns_nav.pattern_cursor;
|
|
ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern });
|
|
}
|
|
}
|
|
KeyCode::Char('m') => {
|
|
let bank = ctx.app.patterns_nav.bank_cursor;
|
|
for pattern in ctx.app.patterns_nav.selected_patterns() {
|
|
ctx.dispatch(AppCommand::StageMute { bank, pattern });
|
|
}
|
|
}
|
|
KeyCode::Char('x') => {
|
|
let bank = ctx.app.patterns_nav.bank_cursor;
|
|
for pattern in ctx.app.patterns_nav.selected_patterns() {
|
|
ctx.dispatch(AppCommand::StageSolo { bank, pattern });
|
|
}
|
|
}
|
|
KeyCode::Char('M') => {
|
|
ctx.dispatch(AppCommand::ClearMutes);
|
|
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
|
}
|
|
KeyCode::Char('X') => {
|
|
ctx.dispatch(AppCommand::ClearSolos);
|
|
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
|
}
|
|
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::Confirm {
|
|
action: ConfirmAction::Quit,
|
|
selected: false,
|
|
}));
|
|
}
|
|
KeyCode::Tab => ctx.dispatch(AppCommand::AudioNextSection),
|
|
KeyCode::BackTab => ctx.dispatch(AppCommand::AudioPrevSection),
|
|
KeyCode::Up => match ctx.app.audio.section {
|
|
EngineSection::Devices => match ctx.app.audio.device_kind {
|
|
DeviceKind::Output => ctx.dispatch(AppCommand::AudioOutputListUp),
|
|
DeviceKind::Input => ctx.dispatch(AppCommand::AudioInputListUp),
|
|
},
|
|
EngineSection::Settings => {
|
|
ctx.dispatch(AppCommand::AudioSettingPrev);
|
|
}
|
|
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.dispatch(AppCommand::AudioOutputListDown(count));
|
|
}
|
|
DeviceKind::Input => {
|
|
let count = ctx.app.audio.input_devices.len();
|
|
ctx.dispatch(AppCommand::AudioInputListDown(count));
|
|
}
|
|
},
|
|
EngineSection::Settings => {
|
|
ctx.dispatch(AppCommand::AudioSettingNext);
|
|
}
|
|
EngineSection::Samples => {}
|
|
},
|
|
KeyCode::PageUp => {
|
|
if ctx.app.audio.section == EngineSection::Devices {
|
|
match ctx.app.audio.device_kind {
|
|
DeviceKind::Output => ctx.dispatch(AppCommand::AudioOutputPageUp),
|
|
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.dispatch(AppCommand::AudioOutputPageDown(count));
|
|
}
|
|
DeviceKind::Input => {
|
|
let count = ctx.app.audio.input_devices.len();
|
|
ctx.dispatch(AppCommand::AudioInputPageDown(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() {
|
|
let name = ctx.app.audio.output_devices[cursor].name.clone();
|
|
ctx.dispatch(AppCommand::SetOutputDevice(name));
|
|
ctx.app.save_settings(ctx.link);
|
|
}
|
|
}
|
|
DeviceKind::Input => {
|
|
let cursor = ctx.app.audio.input_list.cursor;
|
|
if cursor < ctx.app.audio.input_devices.len() {
|
|
let name = ctx.app.audio.input_devices[cursor].name.clone();
|
|
ctx.dispatch(AppCommand::SetInputDevice(name));
|
|
ctx.app.save_settings(ctx.link);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Left => match ctx.app.audio.section {
|
|
EngineSection::Devices => {
|
|
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Output));
|
|
}
|
|
EngineSection::Settings => {
|
|
match ctx.app.audio.setting_kind {
|
|
SettingKind::Channels => ctx.dispatch(AppCommand::AdjustAudioSetting {
|
|
setting: SettingKind::Channels,
|
|
delta: -1,
|
|
}),
|
|
SettingKind::BufferSize => ctx.dispatch(AppCommand::AdjustAudioSetting {
|
|
setting: SettingKind::BufferSize,
|
|
delta: -64,
|
|
}),
|
|
SettingKind::Polyphony => ctx.dispatch(AppCommand::AdjustAudioSetting {
|
|
setting: SettingKind::Polyphony,
|
|
delta: -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.dispatch(AppCommand::SetDeviceKind(DeviceKind::Input));
|
|
}
|
|
EngineSection::Settings => {
|
|
match ctx.app.audio.setting_kind {
|
|
SettingKind::Channels => ctx.dispatch(AppCommand::AdjustAudioSetting {
|
|
setting: SettingKind::Channels,
|
|
delta: 1,
|
|
}),
|
|
SettingKind::BufferSize => ctx.dispatch(AppCommand::AdjustAudioSetting {
|
|
setting: SettingKind::BufferSize,
|
|
delta: 64,
|
|
}),
|
|
SettingKind::Polyphony => ctx.dispatch(AppCommand::AdjustAudioSetting {
|
|
setting: SettingKind::Polyphony,
|
|
delta: 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.dispatch(AppCommand::AudioTriggerRestart),
|
|
KeyCode::Char('A') => {
|
|
use crate::state::file_browser::FileBrowserState;
|
|
let state = FileBrowserState::new_load(String::new());
|
|
ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(Box::new(state))));
|
|
}
|
|
KeyCode::Char('D') => {
|
|
if ctx.app.audio.section == EngineSection::Samples {
|
|
ctx.dispatch(AppCommand::RemoveLastSamplePath);
|
|
} else {
|
|
ctx.dispatch(AppCommand::AudioRefreshDevices);
|
|
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.dispatch(AppCommand::ResetPeakVoices),
|
|
KeyCode::Char('t') => {
|
|
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate {
|
|
cmd: "/sound/sine/dur/0.5/decay/0.2".into(),
|
|
time: None,
|
|
});
|
|
}
|
|
KeyCode::Char('?') => {
|
|
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
|
}
|
|
KeyCode::Char(' ') => {
|
|
ctx.dispatch(AppCommand::TogglePlaying);
|
|
ctx.playing
|
|
.store(ctx.app.playback.playing, Ordering::Relaxed);
|
|
}
|
|
_ => {}
|
|
}
|
|
InputResult::Continue
|
|
}
|
|
|
|
fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|
match key.code {
|
|
KeyCode::Char('q') => {
|
|
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
|
|
action: ConfirmAction::Quit,
|
|
selected: false,
|
|
}));
|
|
}
|
|
KeyCode::Down | KeyCode::Tab => ctx.dispatch(AppCommand::OptionsNextFocus),
|
|
KeyCode::Up | KeyCode::BackTab => ctx.dispatch(AppCommand::OptionsPrevFocus),
|
|
KeyCode::Left | KeyCode::Right => {
|
|
match ctx.app.options.focus {
|
|
OptionsFocus::ColorScheme => {
|
|
let new_scheme = if key.code == KeyCode::Left {
|
|
ctx.app.ui.color_scheme.prev()
|
|
} else {
|
|
ctx.app.ui.color_scheme.next()
|
|
};
|
|
ctx.dispatch(AppCommand::SetColorScheme(new_scheme));
|
|
}
|
|
OptionsFocus::HueRotation => {
|
|
let delta = if key.code == KeyCode::Left { -5.0 } else { 5.0 };
|
|
let new_rotation = (ctx.app.ui.hue_rotation + delta).rem_euclid(360.0);
|
|
ctx.dispatch(AppCommand::SetHueRotation(new_rotation));
|
|
}
|
|
OptionsFocus::RefreshRate => ctx.dispatch(AppCommand::ToggleRefreshRate),
|
|
OptionsFocus::RuntimeHighlight => {
|
|
ctx.dispatch(AppCommand::ToggleRuntimeHighlight);
|
|
}
|
|
OptionsFocus::ShowScope => {
|
|
ctx.dispatch(AppCommand::ToggleScope);
|
|
}
|
|
OptionsFocus::ShowSpectrum => {
|
|
ctx.dispatch(AppCommand::ToggleSpectrum);
|
|
}
|
|
OptionsFocus::ShowCompletion => {
|
|
ctx.dispatch(AppCommand::ToggleCompletion);
|
|
}
|
|
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);
|
|
}
|
|
OptionsFocus::MidiOutput0
|
|
| OptionsFocus::MidiOutput1
|
|
| OptionsFocus::MidiOutput2
|
|
| OptionsFocus::MidiOutput3 => {
|
|
let slot = match ctx.app.options.focus {
|
|
OptionsFocus::MidiOutput0 => 0,
|
|
OptionsFocus::MidiOutput1 => 1,
|
|
OptionsFocus::MidiOutput2 => 2,
|
|
OptionsFocus::MidiOutput3 => 3,
|
|
_ => 0,
|
|
};
|
|
let all_devices = crate::midi::list_midi_outputs();
|
|
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(idx, _)| {
|
|
ctx.app.midi.selected_outputs[slot] == Some(*idx)
|
|
|| !ctx
|
|
.app
|
|
.midi
|
|
.selected_outputs
|
|
.iter()
|
|
.enumerate()
|
|
.any(|(s, sel)| s != slot && *sel == Some(*idx))
|
|
})
|
|
.collect();
|
|
let total_options = available.len() + 1;
|
|
let current_pos = ctx.app.midi.selected_outputs[slot]
|
|
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
|
|
.map(|p| p + 1)
|
|
.unwrap_or(0);
|
|
let new_pos = if key.code == KeyCode::Left {
|
|
if current_pos == 0 {
|
|
total_options - 1
|
|
} else {
|
|
current_pos - 1
|
|
}
|
|
} else {
|
|
(current_pos + 1) % total_options
|
|
};
|
|
if new_pos == 0 {
|
|
ctx.app.midi.disconnect_output(slot);
|
|
ctx.dispatch(AppCommand::SetStatus(format!(
|
|
"MIDI output {slot}: disconnected"
|
|
)));
|
|
} else {
|
|
let (device_idx, device) = available[new_pos - 1];
|
|
if ctx.app.midi.connect_output(slot, device_idx).is_ok() {
|
|
ctx.dispatch(AppCommand::SetStatus(format!(
|
|
"MIDI output {}: {}",
|
|
slot, device.name
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
OptionsFocus::MidiInput0
|
|
| OptionsFocus::MidiInput1
|
|
| OptionsFocus::MidiInput2
|
|
| OptionsFocus::MidiInput3 => {
|
|
let slot = match ctx.app.options.focus {
|
|
OptionsFocus::MidiInput0 => 0,
|
|
OptionsFocus::MidiInput1 => 1,
|
|
OptionsFocus::MidiInput2 => 2,
|
|
OptionsFocus::MidiInput3 => 3,
|
|
_ => 0,
|
|
};
|
|
let all_devices = crate::midi::list_midi_inputs();
|
|
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(idx, _)| {
|
|
ctx.app.midi.selected_inputs[slot] == Some(*idx)
|
|
|| !ctx
|
|
.app
|
|
.midi
|
|
.selected_inputs
|
|
.iter()
|
|
.enumerate()
|
|
.any(|(s, sel)| s != slot && *sel == Some(*idx))
|
|
})
|
|
.collect();
|
|
let total_options = available.len() + 1;
|
|
let current_pos = ctx.app.midi.selected_inputs[slot]
|
|
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
|
|
.map(|p| p + 1)
|
|
.unwrap_or(0);
|
|
let new_pos = if key.code == KeyCode::Left {
|
|
if current_pos == 0 {
|
|
total_options - 1
|
|
} else {
|
|
current_pos - 1
|
|
}
|
|
} else {
|
|
(current_pos + 1) % total_options
|
|
};
|
|
if new_pos == 0 {
|
|
ctx.app.midi.disconnect_input(slot);
|
|
ctx.dispatch(AppCommand::SetStatus(format!(
|
|
"MIDI input {slot}: disconnected"
|
|
)));
|
|
} else {
|
|
let (device_idx, device) = available[new_pos - 1];
|
|
if ctx.app.midi.connect_input(slot, device_idx).is_ok() {
|
|
ctx.dispatch(AppCommand::SetStatus(format!(
|
|
"MIDI input {}: {}",
|
|
slot, device.name
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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 {
|
|
use crate::state::HelpFocus;
|
|
|
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
|
|
|
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) if !ctrl => ctx.dispatch(AppCommand::HelpSearchInput(c)),
|
|
_ => {}
|
|
}
|
|
return InputResult::Continue;
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Char('/') | KeyCode::Char('f') if key.code == KeyCode::Char('/') || ctrl => {
|
|
ctx.dispatch(AppCommand::HelpActivateSearch);
|
|
}
|
|
KeyCode::Esc if !ctx.app.ui.help_search_query.is_empty() => {
|
|
ctx.dispatch(AppCommand::HelpClearSearch);
|
|
}
|
|
KeyCode::Tab => ctx.dispatch(AppCommand::HelpToggleFocus),
|
|
KeyCode::Char('j') | KeyCode::Down if ctrl => {
|
|
ctx.dispatch(AppCommand::HelpNextTopic(5));
|
|
}
|
|
KeyCode::Char('k') | KeyCode::Up if ctrl => {
|
|
ctx.dispatch(AppCommand::HelpPrevTopic(5));
|
|
}
|
|
KeyCode::Char('j') | KeyCode::Down => match ctx.app.ui.help_focus {
|
|
HelpFocus::Topics => ctx.dispatch(AppCommand::HelpNextTopic(1)),
|
|
HelpFocus::Content => ctx.dispatch(AppCommand::HelpScrollDown(1)),
|
|
},
|
|
KeyCode::Char('k') | KeyCode::Up => match ctx.app.ui.help_focus {
|
|
HelpFocus::Topics => ctx.dispatch(AppCommand::HelpPrevTopic(1)),
|
|
HelpFocus::Content => ctx.dispatch(AppCommand::HelpScrollUp(1)),
|
|
},
|
|
KeyCode::PageDown => ctx.dispatch(AppCommand::HelpScrollDown(10)),
|
|
KeyCode::PageUp => ctx.dispatch(AppCommand::HelpScrollUp(10)),
|
|
KeyCode::Char('q') => {
|
|
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
|
|
action: ConfirmAction::Quit,
|
|
selected: false,
|
|
}));
|
|
}
|
|
KeyCode::Char('?') => {
|
|
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
|
}
|
|
KeyCode::Char(' ') => {
|
|
ctx.dispatch(AppCommand::TogglePlaying);
|
|
ctx.playing
|
|
.store(ctx.app.playback.playing, Ordering::Relaxed);
|
|
}
|
|
_ => {}
|
|
}
|
|
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::Confirm {
|
|
action: ConfirmAction::Quit,
|
|
selected: false,
|
|
}));
|
|
}
|
|
KeyCode::Char('?') => {
|
|
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
|
}
|
|
KeyCode::Char(' ') => {
|
|
ctx.dispatch(AppCommand::TogglePlaying);
|
|
ctx.playing
|
|
.store(ctx.app.playback.playing, Ordering::Relaxed);
|
|
}
|
|
_ => {}
|
|
}
|
|
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;
|
|
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::init::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"
|
|
)));
|
|
}
|
|
}
|