1570 lines
55 KiB
Rust
1570 lines
55 KiB
Rust
use rand::rngs::StdRng;
|
|
use rand::SeedableRng;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use crossbeam_channel::Sender;
|
|
|
|
use crate::commands::AppCommand;
|
|
use crate::engine::{
|
|
LinkState, PatternChange, PatternSnapshot, SeqCommand, SequencerSnapshot, StepSnapshot,
|
|
};
|
|
use crate::midi::MidiState;
|
|
use crate::model::{self, Bank, Dictionary, Pattern, Rng, ScriptEngine, StepContext, Variables};
|
|
use crate::page::Page;
|
|
use crate::services::pattern_editor;
|
|
use crate::settings::Settings;
|
|
use crate::state::{
|
|
AudioSettings, CyclicEnum, DictFocus, EditorContext, FlashKind, LiveKeyState, Metrics, Modal,
|
|
OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, PlaybackState,
|
|
ProjectState, StagedChange, UiState,
|
|
};
|
|
use crate::views::{dict_view, help_view};
|
|
|
|
const STEPS_PER_PAGE: usize = 32;
|
|
|
|
pub struct App {
|
|
pub project_state: ProjectState,
|
|
pub ui: UiState,
|
|
pub playback: PlaybackState,
|
|
|
|
pub page: Page,
|
|
pub editor_ctx: EditorContext,
|
|
|
|
pub patterns_nav: PatternsNav,
|
|
|
|
pub metrics: Metrics,
|
|
pub script_engine: ScriptEngine,
|
|
pub variables: Variables,
|
|
pub dict: Dictionary,
|
|
pub rng: Rng,
|
|
pub live_keys: Arc<LiveKeyState>,
|
|
pub clipboard: Option<arboard::Clipboard>,
|
|
pub copied_pattern: Option<Pattern>,
|
|
pub copied_bank: Option<Bank>,
|
|
|
|
pub audio: AudioSettings,
|
|
pub options: OptionsState,
|
|
pub panel: PanelState,
|
|
pub midi: MidiState,
|
|
}
|
|
|
|
impl Default for App {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl App {
|
|
pub fn new() -> Self {
|
|
let variables = Arc::new(Mutex::new(HashMap::new()));
|
|
let dict = Arc::new(Mutex::new(HashMap::new()));
|
|
let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0)));
|
|
let script_engine =
|
|
ScriptEngine::new(Arc::clone(&variables), Arc::clone(&dict), Arc::clone(&rng));
|
|
let live_keys = Arc::new(LiveKeyState::new());
|
|
|
|
Self {
|
|
project_state: ProjectState::default(),
|
|
ui: UiState::default(),
|
|
playback: PlaybackState::default(),
|
|
|
|
page: Page::default(),
|
|
editor_ctx: EditorContext::default(),
|
|
|
|
patterns_nav: PatternsNav::default(),
|
|
|
|
metrics: Metrics::default(),
|
|
variables,
|
|
dict,
|
|
rng,
|
|
live_keys,
|
|
script_engine,
|
|
clipboard: arboard::Clipboard::new().ok(),
|
|
copied_pattern: None,
|
|
copied_bank: None,
|
|
|
|
audio: AudioSettings::default(),
|
|
options: OptionsState::default(),
|
|
panel: PanelState::default(),
|
|
midi: MidiState::new(),
|
|
}
|
|
}
|
|
|
|
pub fn save_settings(&self, link: &LinkState) {
|
|
let settings = Settings {
|
|
audio: crate::settings::AudioSettings {
|
|
output_device: self.audio.config.output_device.clone(),
|
|
input_device: self.audio.config.input_device.clone(),
|
|
channels: self.audio.config.channels,
|
|
buffer_size: self.audio.config.buffer_size,
|
|
max_voices: self.audio.config.max_voices,
|
|
lookahead_ms: self.audio.config.lookahead_ms,
|
|
},
|
|
display: crate::settings::DisplaySettings {
|
|
fps: self.audio.config.refresh_rate.to_fps(),
|
|
runtime_highlight: self.ui.runtime_highlight,
|
|
show_scope: self.audio.config.show_scope,
|
|
show_spectrum: self.audio.config.show_spectrum,
|
|
show_completion: self.ui.show_completion,
|
|
flash_brightness: self.ui.flash_brightness,
|
|
color_scheme: self.ui.color_scheme,
|
|
layout: self.audio.config.layout,
|
|
hue_rotation: self.ui.hue_rotation,
|
|
..Default::default()
|
|
},
|
|
link: crate::settings::LinkSettings {
|
|
enabled: link.is_enabled(),
|
|
tempo: link.tempo(),
|
|
quantum: link.quantum(),
|
|
},
|
|
midi: crate::settings::MidiSettings {
|
|
output_devices: {
|
|
let outputs = crate::midi::list_midi_outputs();
|
|
self.midi
|
|
.selected_outputs
|
|
.iter()
|
|
.map(|opt| {
|
|
opt.and_then(|idx| outputs.get(idx).map(|d| d.name.clone()))
|
|
.unwrap_or_default()
|
|
})
|
|
.collect()
|
|
},
|
|
input_devices: {
|
|
let inputs = crate::midi::list_midi_inputs();
|
|
self.midi
|
|
.selected_inputs
|
|
.iter()
|
|
.map(|opt| {
|
|
opt.and_then(|idx| inputs.get(idx).map(|d| d.name.clone()))
|
|
.unwrap_or_default()
|
|
})
|
|
.collect()
|
|
},
|
|
},
|
|
};
|
|
settings.save();
|
|
}
|
|
|
|
fn current_bank_pattern(&self) -> (usize, usize) {
|
|
(self.editor_ctx.bank, self.editor_ctx.pattern)
|
|
}
|
|
|
|
fn selected_steps(&self) -> Vec<usize> {
|
|
match self.editor_ctx.selection_range() {
|
|
Some(range) => range.collect(),
|
|
None => vec![self.editor_ctx.step],
|
|
}
|
|
}
|
|
|
|
fn annotate_copy_name(name: &Option<String>) -> Option<String> {
|
|
match name {
|
|
Some(n) if !n.ends_with(" (copy)") => Some(format!("{n} (copy)")),
|
|
Some(n) => Some(n.clone()),
|
|
None => Some("(copy)".to_string()),
|
|
}
|
|
}
|
|
|
|
pub fn mark_all_patterns_dirty(&mut self) {
|
|
self.project_state.mark_all_dirty();
|
|
}
|
|
|
|
pub fn toggle_playing(&mut self) {
|
|
self.playback.toggle();
|
|
}
|
|
|
|
pub fn tempo_up(&self, link: &LinkState) {
|
|
let current = link.tempo();
|
|
link.set_tempo((current + 1.0).min(300.0));
|
|
}
|
|
|
|
pub fn tempo_down(&self, link: &LinkState) {
|
|
let current = link.tempo();
|
|
link.set_tempo((current - 1.0).max(20.0));
|
|
}
|
|
|
|
pub fn current_edit_pattern(&self) -> &Pattern {
|
|
let (bank, pattern) = self.current_bank_pattern();
|
|
self.project_state.project.pattern_at(bank, pattern)
|
|
}
|
|
|
|
pub fn next_step(&mut self) {
|
|
let len = self.current_edit_pattern().length;
|
|
self.editor_ctx.step = (self.editor_ctx.step + 1) % len;
|
|
self.load_step_to_editor();
|
|
}
|
|
|
|
pub fn prev_step(&mut self) {
|
|
let len = self.current_edit_pattern().length;
|
|
self.editor_ctx.step = (self.editor_ctx.step + len - 1) % len;
|
|
self.load_step_to_editor();
|
|
}
|
|
|
|
pub fn step_up(&mut self) {
|
|
let len = self.current_edit_pattern().length;
|
|
let page_start = (self.editor_ctx.step / STEPS_PER_PAGE) * STEPS_PER_PAGE;
|
|
let steps_on_page = (page_start + STEPS_PER_PAGE).min(len) - page_start;
|
|
let num_rows = steps_on_page.div_ceil(8);
|
|
let steps_per_row = steps_on_page.div_ceil(num_rows);
|
|
|
|
if self.editor_ctx.step >= steps_per_row {
|
|
self.editor_ctx.step -= steps_per_row;
|
|
} else {
|
|
self.editor_ctx.step = (self.editor_ctx.step + len - steps_per_row) % len;
|
|
}
|
|
self.load_step_to_editor();
|
|
}
|
|
|
|
pub fn step_down(&mut self) {
|
|
let len = self.current_edit_pattern().length;
|
|
let page_start = (self.editor_ctx.step / STEPS_PER_PAGE) * STEPS_PER_PAGE;
|
|
let steps_on_page = (page_start + STEPS_PER_PAGE).min(len) - page_start;
|
|
let num_rows = steps_on_page.div_ceil(8);
|
|
let steps_per_row = steps_on_page.div_ceil(num_rows);
|
|
|
|
self.editor_ctx.step = (self.editor_ctx.step + steps_per_row) % len;
|
|
self.load_step_to_editor();
|
|
}
|
|
|
|
pub fn toggle_steps(&mut self) {
|
|
let (bank, pattern) = self.current_bank_pattern();
|
|
for idx in self.selected_steps() {
|
|
pattern_editor::toggle_step(&mut self.project_state.project, bank, pattern, idx);
|
|
}
|
|
self.project_state.mark_dirty(bank, pattern);
|
|
}
|
|
|
|
pub fn length_increase(&mut self) {
|
|
let (bank, pattern) = self.current_bank_pattern();
|
|
let (change, _) =
|
|
pattern_editor::increase_length(&mut self.project_state.project, bank, pattern);
|
|
self.project_state.mark_dirty(change.bank, change.pattern);
|
|
}
|
|
|
|
pub fn length_decrease(&mut self) {
|
|
let (bank, pattern) = self.current_bank_pattern();
|
|
let (change, new_len) =
|
|
pattern_editor::decrease_length(&mut self.project_state.project, bank, pattern);
|
|
if self.editor_ctx.step >= new_len {
|
|
self.editor_ctx.step = new_len - 1;
|
|
self.load_step_to_editor();
|
|
}
|
|
self.project_state.mark_dirty(change.bank, change.pattern);
|
|
}
|
|
|
|
pub fn speed_increase(&mut self) {
|
|
let (bank, pattern) = self.current_bank_pattern();
|
|
let change = pattern_editor::increase_speed(&mut self.project_state.project, bank, pattern);
|
|
self.project_state.mark_dirty(change.bank, change.pattern);
|
|
}
|
|
|
|
pub fn speed_decrease(&mut self) {
|
|
let (bank, pattern) = self.current_bank_pattern();
|
|
let change = pattern_editor::decrease_speed(&mut self.project_state.project, bank, pattern);
|
|
self.project_state.mark_dirty(change.bank, change.pattern);
|
|
}
|
|
|
|
fn create_step_context(&self, step_idx: usize, link: &LinkState) -> StepContext {
|
|
let (bank, pattern) = self.current_bank_pattern();
|
|
let speed = self
|
|
.project_state
|
|
.project
|
|
.pattern_at(bank, pattern)
|
|
.speed
|
|
.multiplier();
|
|
StepContext {
|
|
step: step_idx,
|
|
beat: link.beat(),
|
|
bank,
|
|
pattern,
|
|
tempo: link.tempo(),
|
|
phase: link.phase(),
|
|
slot: 0,
|
|
runs: 0,
|
|
iter: 0,
|
|
speed,
|
|
fill: false,
|
|
nudge_secs: 0.0,
|
|
cc_access: None,
|
|
#[cfg(feature = "desktop")]
|
|
mouse_x: 0.5,
|
|
#[cfg(feature = "desktop")]
|
|
mouse_y: 0.5,
|
|
#[cfg(feature = "desktop")]
|
|
mouse_down: 0.0,
|
|
}
|
|
}
|
|
|
|
fn load_step_to_editor(&mut self) {
|
|
let (bank, pattern) = self.current_bank_pattern();
|
|
if let Some(script) = pattern_editor::get_step_script(
|
|
&self.project_state.project,
|
|
bank,
|
|
pattern,
|
|
self.editor_ctx.step,
|
|
) {
|
|
let lines: Vec<String> = if script.is_empty() {
|
|
vec![String::new()]
|
|
} else {
|
|
script.lines().map(String::from).collect()
|
|
};
|
|
self.editor_ctx.editor.set_content(lines);
|
|
let candidates = model::WORDS
|
|
.iter()
|
|
.map(|w| cagire_ratatui::CompletionCandidate {
|
|
name: w.name.to_string(),
|
|
signature: w.stack.to_string(),
|
|
description: w.desc.to_string(),
|
|
example: w.example.to_string(),
|
|
})
|
|
.collect();
|
|
self.editor_ctx.editor.set_candidates(candidates);
|
|
self.editor_ctx
|
|
.editor
|
|
.set_completion_enabled(self.ui.show_completion);
|
|
}
|
|
}
|
|
|
|
pub fn save_editor_to_step(&mut self) {
|
|
let text = self.editor_ctx.editor.content();
|
|
let (bank, pattern) = self.current_bank_pattern();
|
|
let change = pattern_editor::set_step_script(
|
|
&mut self.project_state.project,
|
|
bank,
|
|
pattern,
|
|
self.editor_ctx.step,
|
|
text,
|
|
);
|
|
self.project_state.mark_dirty(change.bank, change.pattern);
|
|
}
|
|
|
|
pub fn execute_script_oneshot(
|
|
&self,
|
|
script: &str,
|
|
link: &LinkState,
|
|
audio_tx: &arc_swap::ArcSwap<Sender<crate::engine::AudioCommand>>,
|
|
) -> Result<(), String> {
|
|
let ctx = self.create_step_context(self.editor_ctx.step, link);
|
|
let cmds = self.script_engine.evaluate(script, &ctx)?;
|
|
for cmd in cmds {
|
|
let _ = audio_tx
|
|
.load()
|
|
.send(crate::engine::AudioCommand::Evaluate { cmd, time: None });
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn compile_current_step(&mut self, link: &LinkState) {
|
|
let step_idx = self.editor_ctx.step;
|
|
let (bank, pattern) = self.current_bank_pattern();
|
|
|
|
let script =
|
|
pattern_editor::get_step_script(&self.project_state.project, bank, pattern, step_idx)
|
|
.unwrap_or_default();
|
|
|
|
if script.trim().is_empty() {
|
|
if let Some(step) = self
|
|
.project_state
|
|
.project
|
|
.pattern_at_mut(bank, pattern)
|
|
.step_mut(step_idx)
|
|
{
|
|
step.command = None;
|
|
}
|
|
return;
|
|
}
|
|
|
|
let ctx = self.create_step_context(step_idx, link);
|
|
|
|
match self.script_engine.evaluate(&script, &ctx) {
|
|
Ok(cmds) => {
|
|
if let Some(step) = self
|
|
.project_state
|
|
.project
|
|
.pattern_at_mut(bank, pattern)
|
|
.step_mut(step_idx)
|
|
{
|
|
step.command = if cmds.is_empty() {
|
|
None
|
|
} else {
|
|
Some(cmds.join("\n"))
|
|
};
|
|
}
|
|
self.ui.flash("Script compiled", 150, FlashKind::Info);
|
|
}
|
|
Err(e) => {
|
|
if let Some(step) = self
|
|
.project_state
|
|
.project
|
|
.pattern_at_mut(bank, pattern)
|
|
.step_mut(step_idx)
|
|
{
|
|
step.command = None;
|
|
}
|
|
self.ui
|
|
.flash(&format!("Script error: {e}"), 300, FlashKind::Error);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn compile_all_steps(&mut self, link: &LinkState) {
|
|
let pattern_len = self.current_edit_pattern().length;
|
|
let (bank, pattern) = self.current_bank_pattern();
|
|
|
|
for step_idx in 0..pattern_len {
|
|
let script = pattern_editor::get_step_script(
|
|
&self.project_state.project,
|
|
bank,
|
|
pattern,
|
|
step_idx,
|
|
)
|
|
.unwrap_or_default();
|
|
|
|
if script.trim().is_empty() {
|
|
if let Some(step) = self
|
|
.project_state
|
|
.project
|
|
.pattern_at_mut(bank, pattern)
|
|
.step_mut(step_idx)
|
|
{
|
|
step.command = None;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let ctx = self.create_step_context(step_idx, link);
|
|
|
|
if let Ok(cmds) = self.script_engine.evaluate(&script, &ctx) {
|
|
if let Some(step) = self
|
|
.project_state
|
|
.project
|
|
.pattern_at_mut(bank, pattern)
|
|
.step_mut(step_idx)
|
|
{
|
|
step.command = if cmds.is_empty() {
|
|
None
|
|
} else {
|
|
Some(cmds.join("\n"))
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn stage_pattern_toggle(
|
|
&mut self,
|
|
bank: usize,
|
|
pattern: usize,
|
|
snapshot: &SequencerSnapshot,
|
|
) {
|
|
let is_playing = snapshot.is_playing(bank, pattern);
|
|
let pattern_data = self.project_state.project.pattern_at(bank, pattern);
|
|
|
|
let existing = self.playback.staged_changes.iter().position(|c| {
|
|
c.change.pattern_id().bank == bank && c.change.pattern_id().pattern == pattern
|
|
});
|
|
|
|
if let Some(idx) = existing {
|
|
self.playback.staged_changes.remove(idx);
|
|
self.ui
|
|
.set_status(format!("B{:02}:P{:02} unstaged", bank + 1, pattern + 1));
|
|
} else if is_playing {
|
|
self.playback.staged_changes.push(StagedChange {
|
|
change: PatternChange::Stop { bank, pattern },
|
|
quantization: pattern_data.quantization,
|
|
sync_mode: pattern_data.sync_mode,
|
|
});
|
|
self.ui.set_status(format!(
|
|
"B{:02}:P{:02} staged to stop",
|
|
bank + 1,
|
|
pattern + 1
|
|
));
|
|
} else {
|
|
self.playback.staged_changes.push(StagedChange {
|
|
change: PatternChange::Start { bank, pattern },
|
|
quantization: pattern_data.quantization,
|
|
sync_mode: pattern_data.sync_mode,
|
|
});
|
|
self.ui.set_status(format!(
|
|
"B{:02}:P{:02} staged to play",
|
|
bank + 1,
|
|
pattern + 1
|
|
));
|
|
}
|
|
}
|
|
|
|
pub fn commit_staged_changes(&mut self) {
|
|
if self.playback.staged_changes.is_empty() {
|
|
self.ui.set_status("No changes to commit".to_string());
|
|
return;
|
|
}
|
|
let count = self.playback.staged_changes.len();
|
|
self.playback
|
|
.queued_changes
|
|
.append(&mut self.playback.staged_changes);
|
|
self.ui.set_status(format!("Committed {count} changes"));
|
|
}
|
|
|
|
pub fn clear_staged_changes(&mut self) {
|
|
if self.playback.staged_changes.is_empty() {
|
|
return;
|
|
}
|
|
let count = self.playback.staged_changes.len();
|
|
self.playback.staged_changes.clear();
|
|
self.ui
|
|
.set_status(format!("Cleared {count} staged changes"));
|
|
}
|
|
|
|
pub fn select_edit_pattern(&mut self, pattern: usize) {
|
|
self.editor_ctx.pattern = pattern;
|
|
self.editor_ctx.step = 0;
|
|
self.load_step_to_editor();
|
|
}
|
|
|
|
pub fn select_edit_bank(&mut self, bank: usize) {
|
|
self.editor_ctx.bank = bank;
|
|
self.editor_ctx.pattern = 0;
|
|
self.editor_ctx.step = 0;
|
|
self.load_step_to_editor();
|
|
}
|
|
|
|
pub fn save(&mut self, path: PathBuf, link: &LinkState, snapshot: &SequencerSnapshot) {
|
|
self.save_editor_to_step();
|
|
self.project_state.project.sample_paths = self.audio.config.sample_paths.clone();
|
|
self.project_state.project.tempo = link.tempo();
|
|
self.project_state.project.playing_patterns = snapshot
|
|
.active_patterns
|
|
.iter()
|
|
.map(|p| (p.bank, p.pattern))
|
|
.collect();
|
|
match model::save(&self.project_state.project, &path) {
|
|
Ok(final_path) => {
|
|
self.ui
|
|
.set_status(format!("Saved: {}", final_path.display()));
|
|
self.project_state.file_path = Some(final_path);
|
|
}
|
|
Err(e) => {
|
|
self.ui.set_status(format!("Save error: {e}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn load(&mut self, path: PathBuf, link: &LinkState) {
|
|
match model::load(&path) {
|
|
Ok(project) => {
|
|
let tempo = project.tempo;
|
|
let playing = project.playing_patterns.clone();
|
|
|
|
self.project_state.project = project;
|
|
self.editor_ctx.step = 0;
|
|
self.load_step_to_editor();
|
|
self.compile_all_steps(link);
|
|
self.mark_all_patterns_dirty();
|
|
link.set_tempo(tempo);
|
|
|
|
self.playback.clear_queues();
|
|
self.variables.lock().unwrap().clear();
|
|
self.dict.lock().unwrap().clear();
|
|
|
|
for (bank, pattern) in playing {
|
|
self.playback.queued_changes.push(StagedChange {
|
|
change: PatternChange::Start { bank, pattern },
|
|
quantization: crate::model::LaunchQuantization::Immediate,
|
|
sync_mode: crate::model::SyncMode::Reset,
|
|
});
|
|
}
|
|
|
|
self.ui.set_status(format!("Loaded: {}", path.display()));
|
|
self.project_state.file_path = Some(path);
|
|
}
|
|
Err(e) => {
|
|
self.ui.set_status(format!("Load error: {e}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn delete_step(&mut self, bank: usize, pattern: usize, step: usize) {
|
|
let pat = self.project_state.project.pattern_at_mut(bank, pattern);
|
|
for s in &mut pat.steps {
|
|
if s.source == Some(step) {
|
|
s.source = None;
|
|
s.script.clear();
|
|
s.command = None;
|
|
}
|
|
}
|
|
|
|
let change = pattern_editor::set_step_script(
|
|
&mut self.project_state.project,
|
|
bank,
|
|
pattern,
|
|
step,
|
|
String::new(),
|
|
);
|
|
if let Some(s) = self
|
|
.project_state
|
|
.project
|
|
.pattern_at_mut(bank, pattern)
|
|
.step_mut(step)
|
|
{
|
|
s.command = None;
|
|
s.source = None;
|
|
}
|
|
self.project_state.mark_dirty(change.bank, change.pattern);
|
|
if self.editor_ctx.bank == bank
|
|
&& self.editor_ctx.pattern == pattern
|
|
&& self.editor_ctx.step == step
|
|
{
|
|
self.load_step_to_editor();
|
|
}
|
|
self.ui.flash("Step deleted", 150, FlashKind::Success);
|
|
}
|
|
|
|
pub fn delete_steps(&mut self, bank: usize, pattern: usize, steps: &[usize]) {
|
|
for &step in steps {
|
|
let pat = self.project_state.project.pattern_at_mut(bank, pattern);
|
|
for s in &mut pat.steps {
|
|
if s.source == Some(step) {
|
|
s.source = None;
|
|
s.script.clear();
|
|
s.command = None;
|
|
}
|
|
}
|
|
|
|
let change = pattern_editor::set_step_script(
|
|
&mut self.project_state.project,
|
|
bank,
|
|
pattern,
|
|
step,
|
|
String::new(),
|
|
);
|
|
if let Some(s) = self
|
|
.project_state
|
|
.project
|
|
.pattern_at_mut(bank, pattern)
|
|
.step_mut(step)
|
|
{
|
|
s.command = None;
|
|
s.source = None;
|
|
}
|
|
self.project_state.mark_dirty(change.bank, change.pattern);
|
|
}
|
|
if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern {
|
|
self.load_step_to_editor();
|
|
}
|
|
self.editor_ctx.clear_selection();
|
|
self.ui.flash(
|
|
&format!("{} steps deleted", steps.len()),
|
|
150,
|
|
FlashKind::Success,
|
|
);
|
|
}
|
|
|
|
pub fn reset_pattern(&mut self, bank: usize, pattern: usize) {
|
|
self.project_state.project.banks[bank].patterns[pattern] = Pattern::default();
|
|
self.project_state.mark_dirty(bank, pattern);
|
|
if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern {
|
|
self.load_step_to_editor();
|
|
}
|
|
self.ui.flash("Pattern reset", 150, FlashKind::Success);
|
|
}
|
|
|
|
pub fn reset_bank(&mut self, bank: usize) {
|
|
self.project_state.project.banks[bank] = Bank::default();
|
|
for pattern in 0..self.project_state.project.banks[bank].patterns.len() {
|
|
self.project_state.mark_dirty(bank, pattern);
|
|
}
|
|
if self.editor_ctx.bank == bank {
|
|
self.load_step_to_editor();
|
|
}
|
|
self.ui.flash("Bank reset", 150, FlashKind::Success);
|
|
}
|
|
|
|
pub fn copy_pattern(&mut self, bank: usize, pattern: usize) {
|
|
let pat = self.project_state.project.banks[bank].patterns[pattern].clone();
|
|
self.copied_pattern = Some(pat);
|
|
self.ui.flash("Pattern copied", 150, FlashKind::Success);
|
|
}
|
|
|
|
pub fn paste_pattern(&mut self, bank: usize, pattern: usize) {
|
|
if let Some(src) = &self.copied_pattern {
|
|
let mut pat = src.clone();
|
|
pat.name = Self::annotate_copy_name(&src.name);
|
|
self.project_state.project.banks[bank].patterns[pattern] = pat;
|
|
self.project_state.mark_dirty(bank, pattern);
|
|
if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern {
|
|
self.load_step_to_editor();
|
|
}
|
|
self.ui.flash("Pattern pasted", 150, FlashKind::Success);
|
|
}
|
|
}
|
|
|
|
pub fn copy_bank(&mut self, bank: usize) {
|
|
let b = self.project_state.project.banks[bank].clone();
|
|
self.copied_bank = Some(b);
|
|
self.ui.flash("Bank copied", 150, FlashKind::Success);
|
|
}
|
|
|
|
pub fn paste_bank(&mut self, bank: usize) {
|
|
if let Some(src) = &self.copied_bank {
|
|
let mut b = src.clone();
|
|
b.name = Self::annotate_copy_name(&src.name);
|
|
self.project_state.project.banks[bank] = b;
|
|
for pattern in 0..self.project_state.project.banks[bank].patterns.len() {
|
|
self.project_state.mark_dirty(bank, pattern);
|
|
}
|
|
if self.editor_ctx.bank == bank {
|
|
self.load_step_to_editor();
|
|
}
|
|
self.ui.flash("Bank pasted", 150, FlashKind::Success);
|
|
}
|
|
}
|
|
|
|
pub fn harden_steps(&mut self) {
|
|
let (bank, pattern) = self.current_bank_pattern();
|
|
let indices = self.selected_steps();
|
|
|
|
let pat = self.project_state.project.pattern_at(bank, pattern);
|
|
let resolutions: Vec<(usize, String)> = indices
|
|
.iter()
|
|
.filter_map(|&idx| {
|
|
let step = pat.step(idx)?;
|
|
step.source?;
|
|
let script = pat.resolve_script(idx)?.to_string();
|
|
Some((idx, script))
|
|
})
|
|
.collect();
|
|
|
|
if resolutions.is_empty() {
|
|
self.ui.set_status("No linked steps to harden".to_string());
|
|
return;
|
|
}
|
|
|
|
let count = resolutions.len();
|
|
for (idx, script) in resolutions {
|
|
if let Some(s) = self
|
|
.project_state
|
|
.project
|
|
.pattern_at_mut(bank, pattern)
|
|
.step_mut(idx)
|
|
{
|
|
s.source = None;
|
|
s.script = script;
|
|
}
|
|
}
|
|
|
|
self.project_state.mark_dirty(bank, pattern);
|
|
self.load_step_to_editor();
|
|
self.editor_ctx.clear_selection();
|
|
if count == 1 {
|
|
self.ui.flash("Step hardened", 150, FlashKind::Success);
|
|
} else {
|
|
self.ui
|
|
.flash(&format!("{count} steps hardened"), 150, FlashKind::Success);
|
|
}
|
|
}
|
|
|
|
pub fn copy_steps(&mut self) {
|
|
let (bank, pattern) = self.current_bank_pattern();
|
|
let pat = self.project_state.project.pattern_at(bank, pattern);
|
|
let indices = self.selected_steps();
|
|
|
|
let mut steps = Vec::new();
|
|
let mut scripts = Vec::new();
|
|
for &idx in &indices {
|
|
if let Some(step) = pat.step(idx) {
|
|
let resolved = pat.resolve_script(idx).unwrap_or("").to_string();
|
|
scripts.push(resolved.clone());
|
|
steps.push(crate::state::CopiedStepData {
|
|
script: resolved,
|
|
active: step.active,
|
|
source: step.source,
|
|
original_index: idx,
|
|
name: step.name.clone(),
|
|
});
|
|
}
|
|
}
|
|
|
|
let count = steps.len();
|
|
self.editor_ctx.copied_steps = Some(crate::state::CopiedSteps {
|
|
bank,
|
|
pattern,
|
|
steps,
|
|
});
|
|
|
|
if let Some(clip) = &mut self.clipboard {
|
|
let _ = clip.set_text(scripts.join("\n"));
|
|
}
|
|
|
|
self.ui
|
|
.flash(&format!("Copied {count} steps"), 150, FlashKind::Info);
|
|
}
|
|
|
|
pub fn paste_steps(&mut self, link: &LinkState) {
|
|
let Some(copied) = self.editor_ctx.copied_steps.clone() else {
|
|
self.ui.set_status("Nothing copied".to_string());
|
|
return;
|
|
};
|
|
|
|
let (bank, pattern) = self.current_bank_pattern();
|
|
let pat_len = self.project_state.project.pattern_at(bank, pattern).length;
|
|
let cursor = self.editor_ctx.step;
|
|
|
|
let same_pattern = copied.bank == bank && copied.pattern == pattern;
|
|
for (i, data) in copied.steps.iter().enumerate() {
|
|
let target = cursor + i;
|
|
if target >= pat_len {
|
|
break;
|
|
}
|
|
if let Some(step) = self
|
|
.project_state
|
|
.project
|
|
.pattern_at_mut(bank, pattern)
|
|
.step_mut(target)
|
|
{
|
|
let source = if same_pattern { data.source } else { None };
|
|
step.active = data.active;
|
|
step.source = source;
|
|
step.name = data.name.clone();
|
|
if source.is_some() {
|
|
step.script.clear();
|
|
step.command = None;
|
|
} else {
|
|
step.script = data.script.clone();
|
|
}
|
|
}
|
|
}
|
|
|
|
self.project_state.mark_dirty(bank, pattern);
|
|
self.load_step_to_editor();
|
|
|
|
// Compile affected steps
|
|
for i in 0..copied.steps.len() {
|
|
let target = cursor + i;
|
|
if target >= pat_len {
|
|
break;
|
|
}
|
|
let saved_step = self.editor_ctx.step;
|
|
self.editor_ctx.step = target;
|
|
self.compile_current_step(link);
|
|
self.editor_ctx.step = saved_step;
|
|
}
|
|
|
|
self.editor_ctx.clear_selection();
|
|
self.ui.flash(
|
|
&format!("Pasted {} steps", copied.steps.len()),
|
|
150,
|
|
FlashKind::Success,
|
|
);
|
|
}
|
|
|
|
pub fn link_paste_steps(&mut self) {
|
|
let Some(copied) = self.editor_ctx.copied_steps.clone() else {
|
|
self.ui.set_status("Nothing copied".to_string());
|
|
return;
|
|
};
|
|
|
|
let (bank, pattern) = self.current_bank_pattern();
|
|
|
|
if copied.bank != bank || copied.pattern != pattern {
|
|
self.ui
|
|
.set_status("Can only link within same pattern".to_string());
|
|
return;
|
|
}
|
|
|
|
let pat_len = self.project_state.project.pattern_at(bank, pattern).length;
|
|
let cursor = self.editor_ctx.step;
|
|
|
|
for (i, data) in copied.steps.iter().enumerate() {
|
|
let target = cursor + i;
|
|
if target >= pat_len {
|
|
break;
|
|
}
|
|
let source_idx = if data.source.is_some() {
|
|
// Original was linked, link to same source
|
|
data.source
|
|
} else {
|
|
Some(data.original_index)
|
|
};
|
|
if source_idx == Some(target) {
|
|
continue;
|
|
}
|
|
if let Some(step) = self
|
|
.project_state
|
|
.project
|
|
.pattern_at_mut(bank, pattern)
|
|
.step_mut(target)
|
|
{
|
|
step.source = source_idx;
|
|
step.script.clear();
|
|
step.command = None;
|
|
}
|
|
}
|
|
|
|
self.project_state.mark_dirty(bank, pattern);
|
|
self.load_step_to_editor();
|
|
self.editor_ctx.clear_selection();
|
|
self.ui.flash(
|
|
&format!("Linked {} steps", copied.steps.len()),
|
|
150,
|
|
FlashKind::Success,
|
|
);
|
|
}
|
|
|
|
pub fn duplicate_steps(&mut self, link: &LinkState) {
|
|
let (bank, pattern) = self.current_bank_pattern();
|
|
let pat = self.project_state.project.pattern_at(bank, pattern);
|
|
let pat_len = pat.length;
|
|
let indices = self.selected_steps();
|
|
let count = indices.len();
|
|
let paste_at = *indices.last().unwrap() + 1;
|
|
|
|
let dupe_data: Vec<(bool, String, Option<usize>)> = indices
|
|
.iter()
|
|
.filter_map(|&idx| {
|
|
let step = pat.step(idx)?;
|
|
let script = pat.resolve_script(idx).unwrap_or("").to_string();
|
|
let source = step.source;
|
|
Some((step.active, script, source))
|
|
})
|
|
.collect();
|
|
|
|
let mut pasted = 0;
|
|
for (i, (active, script, source)) in dupe_data.into_iter().enumerate() {
|
|
let target = paste_at + i;
|
|
if target >= pat_len {
|
|
break;
|
|
}
|
|
if let Some(step) = self
|
|
.project_state
|
|
.project
|
|
.pattern_at_mut(bank, pattern)
|
|
.step_mut(target)
|
|
{
|
|
step.active = active;
|
|
step.source = source;
|
|
if source.is_some() {
|
|
step.script.clear();
|
|
step.command = None;
|
|
} else {
|
|
step.script = script;
|
|
step.command = None;
|
|
}
|
|
}
|
|
pasted += 1;
|
|
}
|
|
|
|
self.project_state.mark_dirty(bank, pattern);
|
|
self.load_step_to_editor();
|
|
|
|
for i in 0..pasted {
|
|
let target = paste_at + i;
|
|
let saved = self.editor_ctx.step;
|
|
self.editor_ctx.step = target;
|
|
self.compile_current_step(link);
|
|
self.editor_ctx.step = saved;
|
|
}
|
|
|
|
self.editor_ctx.clear_selection();
|
|
self.ui.flash(
|
|
&format!("Duplicated {count} steps"),
|
|
150,
|
|
FlashKind::Success,
|
|
);
|
|
}
|
|
|
|
pub fn open_pattern_modal(&mut self, field: PatternField) {
|
|
let current = match field {
|
|
PatternField::Length => self.current_edit_pattern().length.to_string(),
|
|
PatternField::Speed => self.current_edit_pattern().speed.label().to_string(),
|
|
};
|
|
self.ui.modal = Modal::SetPattern {
|
|
field,
|
|
input: current,
|
|
};
|
|
}
|
|
|
|
pub fn open_pattern_props_modal(&mut self, bank: usize, pattern: usize) {
|
|
let pat = self.project_state.project.pattern_at(bank, pattern);
|
|
self.ui.modal = Modal::PatternProps {
|
|
bank,
|
|
pattern,
|
|
field: PatternPropsField::default(),
|
|
name: pat.name.clone().unwrap_or_default(),
|
|
length: pat.length.to_string(),
|
|
speed: pat.speed,
|
|
quantization: pat.quantization,
|
|
sync_mode: pat.sync_mode,
|
|
};
|
|
}
|
|
|
|
pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) {
|
|
match cmd {
|
|
// Playback
|
|
AppCommand::TogglePlaying => self.toggle_playing(),
|
|
AppCommand::TempoUp => self.tempo_up(link),
|
|
AppCommand::TempoDown => self.tempo_down(link),
|
|
|
|
// Navigation
|
|
AppCommand::NextStep => self.next_step(),
|
|
AppCommand::PrevStep => self.prev_step(),
|
|
AppCommand::StepUp => self.step_up(),
|
|
AppCommand::StepDown => self.step_down(),
|
|
|
|
// Pattern editing
|
|
AppCommand::ToggleSteps => self.toggle_steps(),
|
|
AppCommand::LengthIncrease => self.length_increase(),
|
|
AppCommand::LengthDecrease => self.length_decrease(),
|
|
AppCommand::SpeedIncrease => self.speed_increase(),
|
|
AppCommand::SpeedDecrease => self.speed_decrease(),
|
|
AppCommand::SetLength {
|
|
bank,
|
|
pattern,
|
|
length,
|
|
} => {
|
|
let (change, new_len) = pattern_editor::set_length(
|
|
&mut self.project_state.project,
|
|
bank,
|
|
pattern,
|
|
length,
|
|
);
|
|
if self.editor_ctx.bank == bank
|
|
&& self.editor_ctx.pattern == pattern
|
|
&& self.editor_ctx.step >= new_len
|
|
{
|
|
self.editor_ctx.step = new_len - 1;
|
|
}
|
|
self.project_state.mark_dirty(change.bank, change.pattern);
|
|
}
|
|
AppCommand::SetSpeed {
|
|
bank,
|
|
pattern,
|
|
speed,
|
|
} => {
|
|
let change = pattern_editor::set_speed(
|
|
&mut self.project_state.project,
|
|
bank,
|
|
pattern,
|
|
speed,
|
|
);
|
|
self.project_state.mark_dirty(change.bank, change.pattern);
|
|
}
|
|
|
|
// Script editing
|
|
AppCommand::SaveEditorToStep => self.save_editor_to_step(),
|
|
AppCommand::CompileCurrentStep => self.compile_current_step(link),
|
|
AppCommand::DeleteStep {
|
|
bank,
|
|
pattern,
|
|
step,
|
|
} => {
|
|
self.delete_step(bank, pattern, step);
|
|
}
|
|
AppCommand::DeleteSteps {
|
|
bank,
|
|
pattern,
|
|
steps,
|
|
} => {
|
|
self.delete_steps(bank, pattern, &steps);
|
|
}
|
|
AppCommand::ResetPattern { bank, pattern } => {
|
|
self.reset_pattern(bank, pattern);
|
|
}
|
|
AppCommand::ResetBank { bank } => {
|
|
self.reset_bank(bank);
|
|
}
|
|
AppCommand::CopyPattern { bank, pattern } => {
|
|
self.copy_pattern(bank, pattern);
|
|
}
|
|
AppCommand::PastePattern { bank, pattern } => {
|
|
self.paste_pattern(bank, pattern);
|
|
}
|
|
AppCommand::CopyBank { bank } => {
|
|
self.copy_bank(bank);
|
|
}
|
|
AppCommand::PasteBank { bank } => {
|
|
self.paste_bank(bank);
|
|
}
|
|
|
|
// Clipboard
|
|
AppCommand::HardenSteps => self.harden_steps(),
|
|
AppCommand::CopySteps => self.copy_steps(),
|
|
AppCommand::PasteSteps => self.paste_steps(link),
|
|
AppCommand::LinkPasteSteps => self.link_paste_steps(),
|
|
AppCommand::DuplicateSteps => self.duplicate_steps(link),
|
|
|
|
// Pattern playback (staging)
|
|
AppCommand::CommitStagedChanges => {
|
|
self.commit_staged_changes();
|
|
}
|
|
AppCommand::ClearStagedChanges => {
|
|
self.clear_staged_changes();
|
|
}
|
|
|
|
// Project
|
|
AppCommand::RenameBank { bank, name } => {
|
|
self.project_state.project.banks[bank].name = name;
|
|
}
|
|
AppCommand::RenamePattern {
|
|
bank,
|
|
pattern,
|
|
name,
|
|
} => {
|
|
self.project_state.project.banks[bank].patterns[pattern].name = name;
|
|
}
|
|
AppCommand::RenameStep {
|
|
bank,
|
|
pattern,
|
|
step,
|
|
name,
|
|
} => {
|
|
if let Some(s) =
|
|
self.project_state.project.banks[bank].patterns[pattern].step_mut(step)
|
|
{
|
|
s.name = name;
|
|
}
|
|
self.project_state.mark_dirty(bank, pattern);
|
|
}
|
|
AppCommand::Save(path) => self.save(path, link, snapshot),
|
|
AppCommand::Load(path) => self.load(path, link),
|
|
|
|
// UI
|
|
AppCommand::SetStatus(msg) => self.ui.set_status(msg),
|
|
AppCommand::ClearStatus => self.ui.clear_status(),
|
|
AppCommand::OpenModal(modal) => {
|
|
if matches!(modal, Modal::Editor) {
|
|
// If current step is a shallow copy, navigate to source step
|
|
let pattern = &self.project_state.project.banks[self.editor_ctx.bank].patterns
|
|
[self.editor_ctx.pattern];
|
|
if let Some(source) = pattern.step(self.editor_ctx.step).and_then(|s| s.source)
|
|
{
|
|
self.editor_ctx.step = source;
|
|
}
|
|
self.load_step_to_editor();
|
|
}
|
|
self.ui.modal = modal;
|
|
}
|
|
AppCommand::CloseModal => self.ui.modal = Modal::None,
|
|
AppCommand::OpenPatternModal(field) => self.open_pattern_modal(field),
|
|
AppCommand::OpenPatternPropsModal { bank, pattern } => {
|
|
self.open_pattern_props_modal(bank, pattern);
|
|
}
|
|
AppCommand::SetPatternProps {
|
|
bank,
|
|
pattern,
|
|
name,
|
|
length,
|
|
speed,
|
|
quantization,
|
|
sync_mode,
|
|
} => {
|
|
let pat = self.project_state.project.pattern_at_mut(bank, pattern);
|
|
pat.name = name;
|
|
if let Some(len) = length {
|
|
pat.set_length(len);
|
|
}
|
|
pat.speed = speed;
|
|
pat.quantization = quantization;
|
|
pat.sync_mode = sync_mode;
|
|
self.project_state.mark_dirty(bank, pattern);
|
|
}
|
|
|
|
// Page navigation
|
|
AppCommand::PageLeft => self.page.left(),
|
|
AppCommand::PageRight => self.page.right(),
|
|
AppCommand::PageUp => self.page.up(),
|
|
AppCommand::PageDown => self.page.down(),
|
|
|
|
// Help navigation
|
|
AppCommand::HelpToggleFocus => {
|
|
use crate::state::HelpFocus;
|
|
self.ui.help_focus = match self.ui.help_focus {
|
|
HelpFocus::Topics => HelpFocus::Content,
|
|
HelpFocus::Content => HelpFocus::Topics,
|
|
};
|
|
}
|
|
AppCommand::HelpNextTopic(n) => {
|
|
let count = help_view::topic_count();
|
|
self.ui.help_topic = (self.ui.help_topic + n) % count;
|
|
}
|
|
AppCommand::HelpPrevTopic(n) => {
|
|
let count = help_view::topic_count();
|
|
self.ui.help_topic = (self.ui.help_topic + count - (n % count)) % count;
|
|
}
|
|
AppCommand::HelpScrollDown(n) => {
|
|
let s = self.ui.help_scroll_mut();
|
|
*s = s.saturating_add(n);
|
|
}
|
|
AppCommand::HelpScrollUp(n) => {
|
|
let s = self.ui.help_scroll_mut();
|
|
*s = s.saturating_sub(n);
|
|
}
|
|
AppCommand::HelpActivateSearch => {
|
|
self.ui.help_search_active = true;
|
|
}
|
|
AppCommand::HelpClearSearch => {
|
|
self.ui.help_search_query.clear();
|
|
self.ui.help_search_active = false;
|
|
}
|
|
AppCommand::HelpSearchInput(c) => {
|
|
self.ui.help_search_query.push(c);
|
|
if let Some((topic, line)) = help_view::find_match(&self.ui.help_search_query) {
|
|
self.ui.help_topic = topic;
|
|
self.ui.help_scrolls[topic] = line;
|
|
}
|
|
}
|
|
AppCommand::HelpSearchBackspace => {
|
|
self.ui.help_search_query.pop();
|
|
if self.ui.help_search_query.is_empty() {
|
|
return;
|
|
}
|
|
if let Some((topic, line)) = help_view::find_match(&self.ui.help_search_query) {
|
|
self.ui.help_topic = topic;
|
|
self.ui.help_scrolls[topic] = line;
|
|
}
|
|
}
|
|
AppCommand::HelpSearchConfirm => {
|
|
self.ui.help_search_active = false;
|
|
}
|
|
|
|
// Dictionary navigation
|
|
AppCommand::DictToggleFocus => {
|
|
self.ui.dict_focus = match self.ui.dict_focus {
|
|
DictFocus::Categories => DictFocus::Words,
|
|
DictFocus::Words => DictFocus::Categories,
|
|
};
|
|
}
|
|
AppCommand::DictNextCategory => {
|
|
let count = dict_view::category_count();
|
|
self.ui.dict_category = (self.ui.dict_category + 1) % count;
|
|
}
|
|
AppCommand::DictPrevCategory => {
|
|
let count = dict_view::category_count();
|
|
self.ui.dict_category = (self.ui.dict_category + count - 1) % count;
|
|
}
|
|
AppCommand::DictScrollDown(n) => {
|
|
let s = self.ui.dict_scroll_mut();
|
|
*s = s.saturating_add(n);
|
|
}
|
|
AppCommand::DictScrollUp(n) => {
|
|
let s = self.ui.dict_scroll_mut();
|
|
*s = s.saturating_sub(n);
|
|
}
|
|
AppCommand::DictActivateSearch => {
|
|
self.ui.dict_search_active = true;
|
|
self.ui.dict_focus = DictFocus::Words;
|
|
}
|
|
AppCommand::DictClearSearch => {
|
|
self.ui.dict_search_query.clear();
|
|
self.ui.dict_search_active = false;
|
|
*self.ui.dict_scroll_mut() = 0;
|
|
}
|
|
AppCommand::DictSearchInput(c) => {
|
|
self.ui.dict_search_query.push(c);
|
|
*self.ui.dict_scroll_mut() = 0;
|
|
}
|
|
AppCommand::DictSearchBackspace => {
|
|
self.ui.dict_search_query.pop();
|
|
*self.ui.dict_scroll_mut() = 0;
|
|
}
|
|
AppCommand::DictSearchConfirm => {
|
|
self.ui.dict_search_active = false;
|
|
}
|
|
|
|
// Patterns view
|
|
AppCommand::PatternsCursorLeft => {
|
|
self.patterns_nav.move_left();
|
|
}
|
|
AppCommand::PatternsCursorRight => {
|
|
self.patterns_nav.move_right();
|
|
}
|
|
AppCommand::PatternsCursorUp => {
|
|
self.patterns_nav.move_up();
|
|
}
|
|
AppCommand::PatternsCursorDown => {
|
|
self.patterns_nav.move_down();
|
|
}
|
|
AppCommand::PatternsEnter => {
|
|
let bank = self.patterns_nav.selected_bank();
|
|
let pattern = self.patterns_nav.selected_pattern();
|
|
self.select_edit_bank(bank);
|
|
self.select_edit_pattern(pattern);
|
|
self.page.down();
|
|
}
|
|
AppCommand::PatternsBack => {
|
|
self.page.down();
|
|
}
|
|
AppCommand::PatternsTogglePlay => {
|
|
let bank = self.patterns_nav.selected_bank();
|
|
let pattern = self.patterns_nav.selected_pattern();
|
|
self.stage_pattern_toggle(bank, pattern, snapshot);
|
|
}
|
|
|
|
// UI state
|
|
AppCommand::ClearMinimap => {
|
|
self.ui.minimap_until = None;
|
|
}
|
|
AppCommand::HideTitle => {
|
|
self.ui.show_title = false;
|
|
}
|
|
AppCommand::ToggleEditorStack => {
|
|
self.editor_ctx.show_stack = !self.editor_ctx.show_stack;
|
|
}
|
|
AppCommand::SetColorScheme(scheme) => {
|
|
self.ui.color_scheme = scheme;
|
|
let base_theme = scheme.to_theme();
|
|
let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, self.ui.hue_rotation);
|
|
crate::theme::set(rotated);
|
|
}
|
|
AppCommand::SetHueRotation(degrees) => {
|
|
self.ui.hue_rotation = degrees;
|
|
let base_theme = self.ui.color_scheme.to_theme();
|
|
let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, degrees);
|
|
crate::theme::set(rotated);
|
|
}
|
|
AppCommand::ToggleRuntimeHighlight => {
|
|
self.ui.runtime_highlight = !self.ui.runtime_highlight;
|
|
}
|
|
AppCommand::ToggleCompletion => {
|
|
self.ui.show_completion = !self.ui.show_completion;
|
|
self.editor_ctx
|
|
.editor
|
|
.set_completion_enabled(self.ui.show_completion);
|
|
}
|
|
AppCommand::AdjustFlashBrightness(delta) => {
|
|
self.ui.flash_brightness = (self.ui.flash_brightness + delta).clamp(0.0, 1.0);
|
|
}
|
|
|
|
// Live keys
|
|
AppCommand::ToggleLiveKeysFill => {
|
|
self.live_keys.flip_fill();
|
|
}
|
|
|
|
// Panel
|
|
AppCommand::ClosePanel => {
|
|
self.panel.visible = false;
|
|
self.panel.focus = crate::state::PanelFocus::Main;
|
|
}
|
|
|
|
// Selection
|
|
AppCommand::SetSelectionAnchor(step) => {
|
|
self.editor_ctx.selection_anchor = Some(step);
|
|
}
|
|
|
|
// Audio settings (engine page)
|
|
AppCommand::AudioNextSection => {
|
|
self.audio.next_section();
|
|
}
|
|
AppCommand::AudioPrevSection => {
|
|
self.audio.prev_section();
|
|
}
|
|
AppCommand::AudioOutputListUp => {
|
|
self.audio.output_list.move_up();
|
|
}
|
|
AppCommand::AudioOutputListDown(count) => {
|
|
self.audio.output_list.move_down(count);
|
|
}
|
|
AppCommand::AudioOutputPageUp => {
|
|
self.audio.output_list.page_up();
|
|
}
|
|
AppCommand::AudioOutputPageDown(count) => {
|
|
self.audio.output_list.page_down(count);
|
|
}
|
|
AppCommand::AudioInputListUp => {
|
|
self.audio.input_list.move_up();
|
|
}
|
|
AppCommand::AudioInputListDown(count) => {
|
|
self.audio.input_list.move_down(count);
|
|
}
|
|
AppCommand::AudioInputPageDown(count) => {
|
|
self.audio.input_list.page_down(count);
|
|
}
|
|
AppCommand::AudioSettingNext => {
|
|
self.audio.setting_kind = self.audio.setting_kind.next();
|
|
}
|
|
AppCommand::AudioSettingPrev => {
|
|
self.audio.setting_kind = self.audio.setting_kind.prev();
|
|
}
|
|
AppCommand::SetOutputDevice(name) => {
|
|
self.audio.config.output_device = Some(name);
|
|
}
|
|
AppCommand::SetInputDevice(name) => {
|
|
self.audio.config.input_device = Some(name);
|
|
}
|
|
AppCommand::SetDeviceKind(kind) => {
|
|
self.audio.device_kind = kind;
|
|
}
|
|
AppCommand::AdjustAudioSetting { setting, delta } => {
|
|
use crate::state::SettingKind;
|
|
match setting {
|
|
SettingKind::Channels => self.audio.adjust_channels(delta as i16),
|
|
SettingKind::BufferSize => self.audio.adjust_buffer_size(delta),
|
|
SettingKind::Polyphony => self.audio.adjust_max_voices(delta),
|
|
SettingKind::Nudge => {
|
|
self.metrics.nudge_ms =
|
|
(self.metrics.nudge_ms + delta as f64).clamp(-50.0, 50.0);
|
|
}
|
|
SettingKind::Lookahead => self.audio.adjust_lookahead(delta),
|
|
}
|
|
}
|
|
AppCommand::AudioTriggerRestart => {
|
|
self.audio.trigger_restart();
|
|
}
|
|
AppCommand::RemoveLastSamplePath => {
|
|
self.audio.remove_last_sample_path();
|
|
}
|
|
AppCommand::AudioRefreshDevices => {
|
|
self.audio.refresh_devices();
|
|
}
|
|
|
|
// Options page
|
|
AppCommand::OptionsNextFocus => {
|
|
self.options.next_focus();
|
|
}
|
|
AppCommand::OptionsPrevFocus => {
|
|
self.options.prev_focus();
|
|
}
|
|
AppCommand::ToggleRefreshRate => {
|
|
self.audio.toggle_refresh_rate();
|
|
}
|
|
AppCommand::ToggleScope => {
|
|
self.audio.config.show_scope = !self.audio.config.show_scope;
|
|
}
|
|
AppCommand::ToggleSpectrum => {
|
|
self.audio.config.show_spectrum = !self.audio.config.show_spectrum;
|
|
}
|
|
|
|
// Metrics
|
|
AppCommand::ResetPeakVoices => {
|
|
self.metrics.peak_voices = 0;
|
|
}
|
|
|
|
// Euclidean distribution
|
|
AppCommand::ApplyEuclideanDistribution {
|
|
bank,
|
|
pattern,
|
|
source_step,
|
|
pulses,
|
|
steps,
|
|
rotation,
|
|
} => {
|
|
let pat_len = self.project_state.project.pattern_at(bank, pattern).length;
|
|
let rhythm = euclidean_rhythm(pulses, steps, rotation);
|
|
|
|
let mut created_count = 0;
|
|
for (i, &is_hit) in rhythm.iter().enumerate() {
|
|
if !is_hit {
|
|
continue;
|
|
}
|
|
|
|
let target = (source_step + i) % pat_len;
|
|
|
|
if target == source_step {
|
|
continue;
|
|
}
|
|
|
|
if let Some(step) = self
|
|
.project_state
|
|
.project
|
|
.pattern_at_mut(bank, pattern)
|
|
.step_mut(target)
|
|
{
|
|
step.source = Some(source_step);
|
|
step.script.clear();
|
|
step.command = None;
|
|
step.active = true;
|
|
}
|
|
created_count += 1;
|
|
}
|
|
|
|
self.project_state.mark_dirty(bank, pattern);
|
|
|
|
for (i, &is_hit) in rhythm.iter().enumerate() {
|
|
if !is_hit || i == 0 {
|
|
continue;
|
|
}
|
|
let target = (source_step + i) % pat_len;
|
|
let saved = self.editor_ctx.step;
|
|
self.editor_ctx.step = target;
|
|
self.compile_current_step(link);
|
|
self.editor_ctx.step = saved;
|
|
}
|
|
|
|
self.load_step_to_editor();
|
|
self.ui.flash(
|
|
&format!("Created {} linked steps (E({pulses},{steps}))", created_count),
|
|
200,
|
|
FlashKind::Success,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn flush_queued_changes(&mut self, cmd_tx: &Sender<SeqCommand>) {
|
|
for staged in self.playback.queued_changes.drain(..) {
|
|
match staged.change {
|
|
PatternChange::Start { bank, pattern } => {
|
|
let _ = cmd_tx.send(SeqCommand::PatternStart {
|
|
bank,
|
|
pattern,
|
|
quantization: staged.quantization,
|
|
sync_mode: staged.sync_mode,
|
|
});
|
|
}
|
|
PatternChange::Stop { bank, pattern } => {
|
|
let _ = cmd_tx.send(SeqCommand::PatternStop {
|
|
bank,
|
|
pattern,
|
|
quantization: staged.quantization,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn flush_dirty_patterns(&mut self, cmd_tx: &Sender<SeqCommand>) {
|
|
for (bank, pattern) in self.project_state.take_dirty() {
|
|
let pat = self.project_state.project.pattern_at(bank, pattern);
|
|
let snapshot = PatternSnapshot {
|
|
speed: pat.speed,
|
|
length: pat.length,
|
|
steps: pat
|
|
.steps
|
|
.iter()
|
|
.take(pat.length)
|
|
.map(|s| StepSnapshot {
|
|
active: s.active,
|
|
script: s.script.clone(),
|
|
source: s.source,
|
|
})
|
|
.collect(),
|
|
quantization: pat.quantization,
|
|
sync_mode: pat.sync_mode,
|
|
};
|
|
let _ = cmd_tx.send(SeqCommand::PatternUpdate {
|
|
bank,
|
|
pattern,
|
|
data: snapshot,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
fn euclidean_rhythm(pulses: usize, steps: usize, rotation: usize) -> Vec<bool> {
|
|
if pulses == 0 || steps == 0 || pulses > steps {
|
|
return vec![false; steps];
|
|
}
|
|
|
|
let mut pattern = vec![false; steps];
|
|
for i in 0..pulses {
|
|
let pos = (i * steps) / pulses;
|
|
pattern[pos] = true;
|
|
}
|
|
|
|
if rotation > 0 {
|
|
pattern.rotate_left(rotation % steps);
|
|
}
|
|
|
|
pattern
|
|
}
|