Fixes
This commit is contained in:
1745
src/input.rs
1745
src/input.rs
File diff suppressed because it is too large
Load Diff
189
src/input/engine_page.rs
Normal file
189
src/input/engine_page.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use super::{InputContext, InputResult};
|
||||
use crate::commands::AppCommand;
|
||||
use crate::engine::{AudioCommand, SeqCommand};
|
||||
use crate::state::{ConfirmAction, DeviceKind, EngineSection, Modal, SettingKind};
|
||||
|
||||
pub(super) 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
|
||||
}
|
||||
114
src/input/help_page.rs
Normal file
114
src/input/help_page.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use super::{InputContext, InputResult};
|
||||
use crate::commands::AppCommand;
|
||||
use crate::state::{ConfirmAction, DictFocus, HelpFocus, Modal};
|
||||
|
||||
pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
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
|
||||
}
|
||||
|
||||
pub(super) fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
|
||||
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
|
||||
}
|
||||
248
src/input/main_page.rs
Normal file
248
src/input/main_page.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use super::{InputContext, InputResult};
|
||||
use crate::commands::AppCommand;
|
||||
use crate::state::{
|
||||
ConfirmAction, CyclicEnum, EuclideanField, Modal, PanelFocus, PatternField, RenameTarget,
|
||||
SampleBrowserState, SidePanel,
|
||||
};
|
||||
|
||||
pub(super) 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
|
||||
}
|
||||
167
src/input/mod.rs
Normal file
167
src/input/mod.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
mod engine_page;
|
||||
mod help_page;
|
||||
mod main_page;
|
||||
mod modal;
|
||||
mod options_page;
|
||||
mod panel;
|
||||
mod patterns_page;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use crossbeam_channel::Sender;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
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::{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_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 {
|
||||
modal::handle_modal_input(ctx, key)
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_live_keys(ctx: &mut InputContext, key: &KeyEvent) -> bool {
|
||||
match (key.code, key.kind) {
|
||||
_ if !matches!(ctx.app.ui.modal, Modal::None) => false,
|
||||
(KeyCode::Char('f'), KeyEventKind::Press) => {
|
||||
ctx.dispatch(AppCommand::ToggleLiveKeysFill);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
|
||||
if ctx.app.panel.visible && ctx.app.panel.focus == PanelFocus::Side {
|
||||
return panel::handle_panel_input(ctx, key);
|
||||
}
|
||||
|
||||
if ctrl {
|
||||
let minimap_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 => main_page::handle_main_page(ctx, key, ctrl),
|
||||
Page::Patterns => patterns_page::handle_patterns_page(ctx, key),
|
||||
Page::Engine => engine_page::handle_engine_page(ctx, key),
|
||||
Page::Options => options_page::handle_options_page(ctx, key),
|
||||
Page::Help => help_page::handle_help_page(ctx, key),
|
||||
Page::Dict => help_page::handle_dict_page(ctx, key),
|
||||
}
|
||||
}
|
||||
|
||||
fn 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"
|
||||
)));
|
||||
}
|
||||
}
|
||||
553
src/input/modal.rs
Normal file
553
src/input/modal.rs
Normal file
@@ -0,0 +1,553 @@
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use super::{InputContext, InputResult};
|
||||
use crate::commands::AppCommand;
|
||||
use crate::engine::SeqCommand;
|
||||
use crate::model::PatternSpeed;
|
||||
use crate::state::{
|
||||
ConfirmAction, EditorTarget, EuclideanField, Modal, PatternField,
|
||||
PatternPropsField, RenameTarget,
|
||||
};
|
||||
|
||||
pub(super) 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));
|
||||
super::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(crate::engine::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 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
178
src/input/options_page.rs
Normal file
178
src/input/options_page.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use super::{InputContext, InputResult};
|
||||
use crate::commands::AppCommand;
|
||||
use crate::state::{ConfirmAction, Modal, OptionsFocus};
|
||||
|
||||
pub(super) 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
|
||||
}
|
||||
95
src/input/panel.rs
Normal file
95
src/input/panel.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use super::{InputContext, InputResult};
|
||||
use crate::commands::AppCommand;
|
||||
use crate::engine::AudioCommand;
|
||||
use crate::state::SidePanel;
|
||||
use cagire_ratatui::TreeLineKind;
|
||||
|
||||
pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
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
|
||||
}
|
||||
245
src/input/patterns_page.rs
Normal file
245
src/input/patterns_page.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use super::{InputContext, InputResult};
|
||||
use crate::commands::AppCommand;
|
||||
use crate::state::{ConfirmAction, Modal, PatternsColumn, RenameTarget};
|
||||
|
||||
pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
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
|
||||
}
|
||||
@@ -26,24 +26,26 @@ use super::{
|
||||
dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view,
|
||||
};
|
||||
|
||||
fn clip_span(span: SourceSpan, line_start: usize, line_len: usize) -> Option<SourceSpan> {
|
||||
let ls = line_start as u32;
|
||||
let ll = line_len as u32;
|
||||
if span.end <= ls || span.start >= ls + ll {
|
||||
return None;
|
||||
}
|
||||
Some(SourceSpan {
|
||||
start: span.start.max(ls) - ls,
|
||||
end: span.end.min(ls + ll) - ls,
|
||||
})
|
||||
}
|
||||
|
||||
fn adjust_spans_for_line(
|
||||
spans: &[SourceSpan],
|
||||
line_start: usize,
|
||||
line_len: usize,
|
||||
) -> Vec<SourceSpan> {
|
||||
let ls = line_start as u32;
|
||||
let ll = line_len as u32;
|
||||
spans
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
if s.end <= ls || s.start >= ls + ll {
|
||||
return None;
|
||||
}
|
||||
Some(SourceSpan {
|
||||
start: s.start.max(ls) - ls,
|
||||
end: (s.end.min(ls + ll)) - ls,
|
||||
})
|
||||
})
|
||||
.filter_map(|s| clip_span(*s, line_start, line_len))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -52,22 +54,9 @@ fn adjust_resolved_for_line(
|
||||
line_start: usize,
|
||||
line_len: usize,
|
||||
) -> Vec<(SourceSpan, String)> {
|
||||
let ls = line_start as u32;
|
||||
let ll = line_len as u32;
|
||||
resolved
|
||||
.iter()
|
||||
.filter_map(|(s, display)| {
|
||||
if s.end <= ls || s.start >= ls + ll {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
SourceSpan {
|
||||
start: s.start.max(ls) - ls,
|
||||
end: (s.end.min(ls + ll)) - ls,
|
||||
},
|
||||
display.clone(),
|
||||
))
|
||||
})
|
||||
.filter_map(|(s, display)| clip_span(*s, line_start, line_len).map(|cs| (cs, display.clone())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -564,252 +553,8 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
.height(18)
|
||||
.render_centered(frame, term)
|
||||
}
|
||||
Modal::Preview => {
|
||||
let width = (term.width * 80 / 100).max(40);
|
||||
let height = (term.height * 80 / 100).max(10);
|
||||
|
||||
let pattern = app.current_edit_pattern();
|
||||
let step_idx = app.editor_ctx.step;
|
||||
let step = pattern.step(step_idx);
|
||||
let source_idx = step.and_then(|s| s.source);
|
||||
let step_name = step.and_then(|s| s.name.as_ref());
|
||||
|
||||
let title = match (source_idx, step_name) {
|
||||
(Some(src), Some(name)) => {
|
||||
format!("Step {:02}: {} → {:02}", step_idx + 1, name, src + 1)
|
||||
}
|
||||
(None, Some(name)) => format!("Step {:02}: {}", step_idx + 1, name),
|
||||
(Some(src), None) => format!("Step {:02} → {:02}", step_idx + 1, src + 1),
|
||||
(None, None) => format!("Step {:02}", step_idx + 1),
|
||||
};
|
||||
|
||||
let inner = ModalFrame::new(&title)
|
||||
.width(width)
|
||||
.height(height)
|
||||
.border_color(theme.modal.preview)
|
||||
.render_centered(frame, term);
|
||||
|
||||
let script = pattern.resolve_script(step_idx).unwrap_or("");
|
||||
if script.is_empty() {
|
||||
let empty = Paragraph::new("(empty)")
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::new().fg(theme.ui.text_dim));
|
||||
let centered_area = Rect {
|
||||
y: inner.y + inner.height / 2,
|
||||
height: 1,
|
||||
..inner
|
||||
};
|
||||
frame.render_widget(empty, centered_area);
|
||||
} else {
|
||||
let trace = if app.ui.runtime_highlight && app.playback.playing {
|
||||
let source = pattern.resolve_source(step_idx);
|
||||
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let resolved_display: Vec<(SourceSpan, String)> = trace
|
||||
.map(|t| {
|
||||
t.resolved
|
||||
.iter()
|
||||
.map(|(s, v)| (*s, v.display()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut line_start = 0usize;
|
||||
let lines: Vec<Line> = script
|
||||
.lines()
|
||||
.map(|line_str| {
|
||||
let tokens = if let Some(t) = trace {
|
||||
let exec = adjust_spans_for_line(
|
||||
&t.executed_spans,
|
||||
line_start,
|
||||
line_str.len(),
|
||||
);
|
||||
let sel = adjust_spans_for_line(
|
||||
&t.selected_spans,
|
||||
line_start,
|
||||
line_str.len(),
|
||||
);
|
||||
let res = adjust_resolved_for_line(
|
||||
&resolved_display,
|
||||
line_start,
|
||||
line_str.len(),
|
||||
);
|
||||
highlight_line_with_runtime(line_str, &exec, &sel, &res, &user_words)
|
||||
} else {
|
||||
highlight_line_with_runtime(line_str, &[], &[], &[], &user_words)
|
||||
};
|
||||
line_start += line_str.len() + 1;
|
||||
let spans: Vec<Span> = tokens
|
||||
.into_iter()
|
||||
.map(|(style, text, _)| Span::styled(text, style))
|
||||
.collect();
|
||||
Line::from(spans)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let paragraph = Paragraph::new(lines);
|
||||
frame.render_widget(paragraph, inner);
|
||||
}
|
||||
|
||||
inner
|
||||
}
|
||||
Modal::Editor => {
|
||||
let width = (term.width * 80 / 100).max(40);
|
||||
let height = (term.height * 60 / 100).max(10);
|
||||
|
||||
let flash_kind = app.ui.flash_kind();
|
||||
let border_color = match flash_kind {
|
||||
Some(FlashKind::Error) => theme.flash.error_fg,
|
||||
Some(FlashKind::Info) => theme.ui.text_primary,
|
||||
Some(FlashKind::Success) => theme.flash.success_fg,
|
||||
None => theme.modal.editor,
|
||||
};
|
||||
|
||||
let title = match app.editor_ctx.target {
|
||||
EditorTarget::Prelude => "Prelude".to_string(),
|
||||
EditorTarget::Step => {
|
||||
let step_num = app.editor_ctx.step + 1;
|
||||
let step = app.current_edit_pattern().step(app.editor_ctx.step);
|
||||
if let Some(ref name) = step.and_then(|s| s.name.as_ref()) {
|
||||
format!("Step {step_num:02}: {name}")
|
||||
} else {
|
||||
format!("Step {step_num:02} Script")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let inner = ModalFrame::new(&title)
|
||||
.width(width)
|
||||
.height(height)
|
||||
.border_color(border_color)
|
||||
.render_centered(frame, term);
|
||||
|
||||
let trace = if app.ui.runtime_highlight
|
||||
&& app.playback.playing
|
||||
&& app.editor_ctx.target == EditorTarget::Step
|
||||
{
|
||||
let source = app
|
||||
.current_edit_pattern()
|
||||
.resolve_source(app.editor_ctx.step);
|
||||
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let text_lines = app.editor_ctx.editor.lines();
|
||||
let mut line_offsets: Vec<usize> = Vec::with_capacity(text_lines.len());
|
||||
let mut offset = 0;
|
||||
for line in text_lines.iter() {
|
||||
line_offsets.push(offset);
|
||||
offset += line.len() + 1;
|
||||
}
|
||||
|
||||
let resolved_display: Vec<(SourceSpan, String)> = trace
|
||||
.map(|t| {
|
||||
t.resolved
|
||||
.iter()
|
||||
.map(|(s, v)| (*s, v.display()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let highlighter = |row: usize, line: &str| -> Vec<(Style, String, bool)> {
|
||||
let line_start = line_offsets[row];
|
||||
let (exec, sel, res) = match trace {
|
||||
Some(t) => (
|
||||
adjust_spans_for_line(&t.executed_spans, line_start, line.len()),
|
||||
adjust_spans_for_line(&t.selected_spans, line_start, line.len()),
|
||||
adjust_resolved_for_line(&resolved_display, line_start, line.len()),
|
||||
),
|
||||
None => (Vec::new(), Vec::new(), Vec::new()),
|
||||
};
|
||||
highlight::highlight_line_with_runtime(line, &exec, &sel, &res, &user_words)
|
||||
};
|
||||
|
||||
let show_search = app.editor_ctx.editor.search_active()
|
||||
|| !app.editor_ctx.editor.search_query().is_empty();
|
||||
|
||||
let reserved_lines = 1 + if show_search { 1 } else { 0 };
|
||||
let editor_height = inner.height.saturating_sub(reserved_lines);
|
||||
|
||||
let mut y = inner.y;
|
||||
|
||||
let search_area = if show_search {
|
||||
let area = Rect::new(inner.x, y, inner.width, 1);
|
||||
y += 1;
|
||||
Some(area)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let editor_area = Rect::new(inner.x, y, inner.width, editor_height);
|
||||
y += editor_height;
|
||||
|
||||
let hint_area = Rect::new(inner.x, y, inner.width, 1);
|
||||
|
||||
if let Some(sa) = search_area {
|
||||
render_search_bar(
|
||||
frame,
|
||||
sa,
|
||||
app.editor_ctx.editor.search_query(),
|
||||
app.editor_ctx.editor.search_active(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(kind) = flash_kind {
|
||||
let bg = match kind {
|
||||
FlashKind::Error => theme.flash.error_bg,
|
||||
FlashKind::Info => theme.flash.info_bg,
|
||||
FlashKind::Success => theme.flash.success_bg,
|
||||
};
|
||||
let flash_block = Block::default().style(Style::default().bg(bg));
|
||||
frame.render_widget(flash_block, editor_area);
|
||||
}
|
||||
app.editor_ctx
|
||||
.editor
|
||||
.render(frame, editor_area, &highlighter);
|
||||
|
||||
if app.editor_ctx.editor.search_active() {
|
||||
let hints = hint_line(&[("Enter", "confirm"), ("Esc", "cancel")]);
|
||||
frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area);
|
||||
} else if app.editor_ctx.show_stack {
|
||||
let stack_text = app
|
||||
.editor_ctx
|
||||
.stack_cache
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|c| c.result.clone())
|
||||
.unwrap_or_else(|| "Stack: []".to_string());
|
||||
let hints = hint_line(&[("Esc", "save"), ("C-e", "eval"), ("C-s", "hide")]);
|
||||
let [hint_left, stack_right] = Layout::horizontal([
|
||||
Constraint::Length(hints.width() as u16),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(hint_area);
|
||||
frame.render_widget(Paragraph::new(hints), hint_left);
|
||||
let dim = Style::default().fg(theme.hint.text);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(stack_text, dim)).alignment(Alignment::Right),
|
||||
stack_right,
|
||||
);
|
||||
} else {
|
||||
let hints = hint_line(&[
|
||||
("Esc", "save"),
|
||||
("C-e", "eval"),
|
||||
("C-f", "find"),
|
||||
("C-b", "samples"),
|
||||
("C-s", "stack"),
|
||||
("C-u", "/"),
|
||||
("C-r", "undo/redo"),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area);
|
||||
}
|
||||
|
||||
inner
|
||||
}
|
||||
Modal::Preview => render_modal_preview(frame, app, snapshot, &user_words, term),
|
||||
Modal::Editor => render_modal_editor(frame, app, snapshot, &user_words, term),
|
||||
Modal::PatternProps {
|
||||
bank,
|
||||
pattern,
|
||||
@@ -846,72 +591,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
|
||||
inner
|
||||
}
|
||||
Modal::KeybindingsHelp { scroll } => {
|
||||
let width = (term.width * 80 / 100).clamp(60, 100);
|
||||
let height = (term.height * 80 / 100).max(15);
|
||||
|
||||
let title = format!("Keybindings — {}", app.page.name());
|
||||
let inner = ModalFrame::new(&title)
|
||||
.width(width)
|
||||
.height(height)
|
||||
.border_color(theme.modal.editor)
|
||||
.render_centered(frame, term);
|
||||
|
||||
let bindings = super::keybindings::bindings_for(app.page);
|
||||
let visible_rows = inner.height.saturating_sub(2) as usize;
|
||||
|
||||
let rows: Vec<Row> = bindings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(*scroll)
|
||||
.take(visible_rows)
|
||||
.map(|(i, (key, name, desc))| {
|
||||
let bg = if i % 2 == 0 {
|
||||
theme.table.row_even
|
||||
} else {
|
||||
theme.table.row_odd
|
||||
};
|
||||
Row::new(vec![
|
||||
Cell::from(*key).style(Style::default().fg(theme.modal.confirm)),
|
||||
Cell::from(*name).style(Style::default().fg(theme.modal.input)),
|
||||
Cell::from(*desc).style(Style::default().fg(theme.ui.text_primary)),
|
||||
])
|
||||
.style(Style::default().bg(bg))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let table = Table::new(
|
||||
rows,
|
||||
[
|
||||
Constraint::Length(14),
|
||||
Constraint::Length(12),
|
||||
Constraint::Fill(1),
|
||||
],
|
||||
)
|
||||
.column_spacing(2);
|
||||
|
||||
let table_area = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y,
|
||||
width: inner.width,
|
||||
height: inner.height.saturating_sub(1),
|
||||
};
|
||||
frame.render_widget(table, table_area);
|
||||
|
||||
let hint_area = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y + inner.height.saturating_sub(1),
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
};
|
||||
let hints = hint_line(&[("↑↓", "scroll"), ("PgUp/Dn", "page"), ("Esc/?", "close")]);
|
||||
frame.render_widget(
|
||||
Paragraph::new(hints).alignment(Alignment::Right),
|
||||
hint_area,
|
||||
);
|
||||
|
||||
inner
|
||||
}
|
||||
Modal::KeybindingsHelp { scroll } => render_modal_keybindings(frame, app, *scroll, term),
|
||||
Modal::EuclideanDistribution {
|
||||
source_step,
|
||||
field,
|
||||
@@ -969,6 +649,336 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
))
|
||||
}
|
||||
|
||||
fn render_modal_preview(
|
||||
frame: &mut Frame,
|
||||
app: &App,
|
||||
snapshot: &SequencerSnapshot,
|
||||
user_words: &HashSet<String>,
|
||||
term: Rect,
|
||||
) -> Rect {
|
||||
let theme = theme::get();
|
||||
let width = (term.width * 80 / 100).max(40);
|
||||
let height = (term.height * 80 / 100).max(10);
|
||||
|
||||
let pattern = app.current_edit_pattern();
|
||||
let step_idx = app.editor_ctx.step;
|
||||
let step = pattern.step(step_idx);
|
||||
let source_idx = step.and_then(|s| s.source);
|
||||
let step_name = step.and_then(|s| s.name.as_ref());
|
||||
|
||||
let title = match (source_idx, step_name) {
|
||||
(Some(src), Some(name)) => {
|
||||
format!("Step {:02}: {} → {:02}", step_idx + 1, name, src + 1)
|
||||
}
|
||||
(None, Some(name)) => format!("Step {:02}: {}", step_idx + 1, name),
|
||||
(Some(src), None) => format!("Step {:02} → {:02}", step_idx + 1, src + 1),
|
||||
(None, None) => format!("Step {:02}", step_idx + 1),
|
||||
};
|
||||
|
||||
let inner = ModalFrame::new(&title)
|
||||
.width(width)
|
||||
.height(height)
|
||||
.border_color(theme.modal.preview)
|
||||
.render_centered(frame, term);
|
||||
|
||||
let script = pattern.resolve_script(step_idx).unwrap_or("");
|
||||
if script.is_empty() {
|
||||
let empty = Paragraph::new("(empty)")
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::new().fg(theme.ui.text_dim));
|
||||
let centered_area = Rect {
|
||||
y: inner.y + inner.height / 2,
|
||||
height: 1,
|
||||
..inner
|
||||
};
|
||||
frame.render_widget(empty, centered_area);
|
||||
} else {
|
||||
let trace = if app.ui.runtime_highlight && app.playback.playing {
|
||||
let source = pattern.resolve_source(step_idx);
|
||||
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let resolved_display: Vec<(SourceSpan, String)> = trace
|
||||
.map(|t| {
|
||||
t.resolved
|
||||
.iter()
|
||||
.map(|(s, v)| (*s, v.display()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut line_start = 0usize;
|
||||
let lines: Vec<Line> = script
|
||||
.lines()
|
||||
.map(|line_str| {
|
||||
let tokens = if let Some(t) = trace {
|
||||
let exec = adjust_spans_for_line(
|
||||
&t.executed_spans,
|
||||
line_start,
|
||||
line_str.len(),
|
||||
);
|
||||
let sel = adjust_spans_for_line(
|
||||
&t.selected_spans,
|
||||
line_start,
|
||||
line_str.len(),
|
||||
);
|
||||
let res = adjust_resolved_for_line(
|
||||
&resolved_display,
|
||||
line_start,
|
||||
line_str.len(),
|
||||
);
|
||||
highlight_line_with_runtime(line_str, &exec, &sel, &res, user_words)
|
||||
} else {
|
||||
highlight_line_with_runtime(line_str, &[], &[], &[], user_words)
|
||||
};
|
||||
line_start += line_str.len() + 1;
|
||||
let spans: Vec<Span> = tokens
|
||||
.into_iter()
|
||||
.map(|(style, text, _)| Span::styled(text, style))
|
||||
.collect();
|
||||
Line::from(spans)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let paragraph = Paragraph::new(lines);
|
||||
frame.render_widget(paragraph, inner);
|
||||
}
|
||||
|
||||
inner
|
||||
}
|
||||
|
||||
fn render_modal_editor(
|
||||
frame: &mut Frame,
|
||||
app: &App,
|
||||
snapshot: &SequencerSnapshot,
|
||||
user_words: &HashSet<String>,
|
||||
term: Rect,
|
||||
) -> Rect {
|
||||
let theme = theme::get();
|
||||
let width = (term.width * 80 / 100).max(40);
|
||||
let height = (term.height * 60 / 100).max(10);
|
||||
|
||||
let flash_kind = app.ui.flash_kind();
|
||||
let border_color = match flash_kind {
|
||||
Some(FlashKind::Error) => theme.flash.error_fg,
|
||||
Some(FlashKind::Info) => theme.ui.text_primary,
|
||||
Some(FlashKind::Success) => theme.flash.success_fg,
|
||||
None => theme.modal.editor,
|
||||
};
|
||||
|
||||
let title = match app.editor_ctx.target {
|
||||
EditorTarget::Prelude => "Prelude".to_string(),
|
||||
EditorTarget::Step => {
|
||||
let step_num = app.editor_ctx.step + 1;
|
||||
let step = app.current_edit_pattern().step(app.editor_ctx.step);
|
||||
if let Some(ref name) = step.and_then(|s| s.name.as_ref()) {
|
||||
format!("Step {step_num:02}: {name}")
|
||||
} else {
|
||||
format!("Step {step_num:02} Script")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let inner = ModalFrame::new(&title)
|
||||
.width(width)
|
||||
.height(height)
|
||||
.border_color(border_color)
|
||||
.render_centered(frame, term);
|
||||
|
||||
let trace = if app.ui.runtime_highlight
|
||||
&& app.playback.playing
|
||||
&& app.editor_ctx.target == EditorTarget::Step
|
||||
{
|
||||
let source = app
|
||||
.current_edit_pattern()
|
||||
.resolve_source(app.editor_ctx.step);
|
||||
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let text_lines = app.editor_ctx.editor.lines();
|
||||
let mut line_offsets: Vec<usize> = Vec::with_capacity(text_lines.len());
|
||||
let mut offset = 0;
|
||||
for line in text_lines.iter() {
|
||||
line_offsets.push(offset);
|
||||
offset += line.len() + 1;
|
||||
}
|
||||
|
||||
let resolved_display: Vec<(SourceSpan, String)> = trace
|
||||
.map(|t| {
|
||||
t.resolved
|
||||
.iter()
|
||||
.map(|(s, v)| (*s, v.display()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let highlighter = |row: usize, line: &str| -> Vec<(Style, String, bool)> {
|
||||
let line_start = line_offsets[row];
|
||||
let (exec, sel, res) = match trace {
|
||||
Some(t) => (
|
||||
adjust_spans_for_line(&t.executed_spans, line_start, line.len()),
|
||||
adjust_spans_for_line(&t.selected_spans, line_start, line.len()),
|
||||
adjust_resolved_for_line(&resolved_display, line_start, line.len()),
|
||||
),
|
||||
None => (Vec::new(), Vec::new(), Vec::new()),
|
||||
};
|
||||
highlight::highlight_line_with_runtime(line, &exec, &sel, &res, user_words)
|
||||
};
|
||||
|
||||
let show_search = app.editor_ctx.editor.search_active()
|
||||
|| !app.editor_ctx.editor.search_query().is_empty();
|
||||
|
||||
let reserved_lines = 1 + if show_search { 1 } else { 0 };
|
||||
let editor_height = inner.height.saturating_sub(reserved_lines);
|
||||
|
||||
let mut y = inner.y;
|
||||
|
||||
let search_area = if show_search {
|
||||
let area = Rect::new(inner.x, y, inner.width, 1);
|
||||
y += 1;
|
||||
Some(area)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let editor_area = Rect::new(inner.x, y, inner.width, editor_height);
|
||||
y += editor_height;
|
||||
|
||||
let hint_area = Rect::new(inner.x, y, inner.width, 1);
|
||||
|
||||
if let Some(sa) = search_area {
|
||||
render_search_bar(
|
||||
frame,
|
||||
sa,
|
||||
app.editor_ctx.editor.search_query(),
|
||||
app.editor_ctx.editor.search_active(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(kind) = flash_kind {
|
||||
let bg = match kind {
|
||||
FlashKind::Error => theme.flash.error_bg,
|
||||
FlashKind::Info => theme.flash.info_bg,
|
||||
FlashKind::Success => theme.flash.success_bg,
|
||||
};
|
||||
let flash_block = Block::default().style(Style::default().bg(bg));
|
||||
frame.render_widget(flash_block, editor_area);
|
||||
}
|
||||
app.editor_ctx
|
||||
.editor
|
||||
.render(frame, editor_area, &highlighter);
|
||||
|
||||
if app.editor_ctx.editor.search_active() {
|
||||
let hints = hint_line(&[("Enter", "confirm"), ("Esc", "cancel")]);
|
||||
frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area);
|
||||
} else if app.editor_ctx.show_stack {
|
||||
let stack_text = app
|
||||
.editor_ctx
|
||||
.stack_cache
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|c| c.result.clone())
|
||||
.unwrap_or_else(|| "Stack: []".to_string());
|
||||
let hints = hint_line(&[("Esc", "save"), ("C-e", "eval"), ("C-s", "hide")]);
|
||||
let [hint_left, stack_right] = Layout::horizontal([
|
||||
Constraint::Length(hints.width() as u16),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(hint_area);
|
||||
frame.render_widget(Paragraph::new(hints), hint_left);
|
||||
let dim = Style::default().fg(theme.hint.text);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(stack_text, dim)).alignment(Alignment::Right),
|
||||
stack_right,
|
||||
);
|
||||
} else {
|
||||
let hints = hint_line(&[
|
||||
("Esc", "save"),
|
||||
("C-e", "eval"),
|
||||
("C-f", "find"),
|
||||
("C-b", "samples"),
|
||||
("C-s", "stack"),
|
||||
("C-u", "/"),
|
||||
("C-r", "undo/redo"),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area);
|
||||
}
|
||||
|
||||
inner
|
||||
}
|
||||
|
||||
fn render_modal_keybindings(frame: &mut Frame, app: &App, scroll: usize, term: Rect) -> Rect {
|
||||
let theme = theme::get();
|
||||
let width = (term.width * 80 / 100).clamp(60, 100);
|
||||
let height = (term.height * 80 / 100).max(15);
|
||||
|
||||
let title = format!("Keybindings — {}", app.page.name());
|
||||
let inner = ModalFrame::new(&title)
|
||||
.width(width)
|
||||
.height(height)
|
||||
.border_color(theme.modal.editor)
|
||||
.render_centered(frame, term);
|
||||
|
||||
let bindings = super::keybindings::bindings_for(app.page);
|
||||
let visible_rows = inner.height.saturating_sub(2) as usize;
|
||||
|
||||
let rows: Vec<Row> = bindings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(scroll)
|
||||
.take(visible_rows)
|
||||
.map(|(i, (key, name, desc))| {
|
||||
let bg = if i % 2 == 0 {
|
||||
theme.table.row_even
|
||||
} else {
|
||||
theme.table.row_odd
|
||||
};
|
||||
Row::new(vec![
|
||||
Cell::from(*key).style(Style::default().fg(theme.modal.confirm)),
|
||||
Cell::from(*name).style(Style::default().fg(theme.modal.input)),
|
||||
Cell::from(*desc).style(Style::default().fg(theme.ui.text_primary)),
|
||||
])
|
||||
.style(Style::default().bg(bg))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let table = Table::new(
|
||||
rows,
|
||||
[
|
||||
Constraint::Length(14),
|
||||
Constraint::Length(12),
|
||||
Constraint::Fill(1),
|
||||
],
|
||||
)
|
||||
.column_spacing(2);
|
||||
|
||||
let table_area = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y,
|
||||
width: inner.width,
|
||||
height: inner.height.saturating_sub(1),
|
||||
};
|
||||
frame.render_widget(table, table_area);
|
||||
|
||||
let hint_area = Rect {
|
||||
x: inner.x,
|
||||
y: inner.y + inner.height.saturating_sub(1),
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
};
|
||||
let hints = hint_line(&[("↑↓", "scroll"), ("PgUp/Dn", "page"), ("Esc/?", "close")]);
|
||||
frame.render_widget(
|
||||
Paragraph::new(hints).alignment(Alignment::Right),
|
||||
hint_area,
|
||||
);
|
||||
|
||||
inner
|
||||
}
|
||||
|
||||
fn format_euclidean_preview(pulses: usize, steps: usize, rotation: usize) -> String {
|
||||
if pulses == 0 || steps == 0 || pulses > steps {
|
||||
return "[invalid]".to_string();
|
||||
|
||||
Reference in New Issue
Block a user