Feat: refactoring codebase
This commit is contained in:
1724
src/app.rs
1724
src/app.rs
File diff suppressed because it is too large
Load Diff
287
src/app/clipboard.rs
Normal file
287
src/app/clipboard.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
use crate::model;
|
||||
use crate::services::clipboard;
|
||||
use crate::state::FlashKind;
|
||||
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
pub fn copy_pattern(&mut self, bank: usize, pattern: usize) {
|
||||
self.copied_patterns = Some(vec![clipboard::copy_pattern(
|
||||
&self.project_state.project,
|
||||
bank,
|
||||
pattern,
|
||||
)]);
|
||||
self.ui.flash("Pattern copied", 150, FlashKind::Success);
|
||||
}
|
||||
|
||||
pub fn paste_pattern(&mut self, bank: usize, pattern: usize) {
|
||||
if let Some(src) = self.copied_patterns.as_ref().and_then(|v| v.first()) {
|
||||
let src = src.clone();
|
||||
clipboard::paste_pattern(&mut self.project_state.project, bank, pattern, &src);
|
||||
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_patterns(&mut self, bank: usize, patterns: &[usize]) {
|
||||
self.copied_patterns = Some(clipboard::copy_patterns(
|
||||
&self.project_state.project,
|
||||
bank,
|
||||
patterns,
|
||||
));
|
||||
let n = patterns.len();
|
||||
self.ui.flash(
|
||||
&format!("{n} pattern{} copied", if n == 1 { "" } else { "s" }),
|
||||
150,
|
||||
FlashKind::Success,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn paste_patterns(&mut self, bank: usize, start: usize) {
|
||||
if let Some(sources) = self.copied_patterns.clone() {
|
||||
let count = clipboard::paste_patterns(
|
||||
&mut self.project_state.project,
|
||||
bank,
|
||||
start,
|
||||
&sources,
|
||||
);
|
||||
for i in 0..count {
|
||||
self.project_state.mark_dirty(bank, start + i);
|
||||
}
|
||||
if self.editor_ctx.bank == bank {
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
self.ui.flash(
|
||||
&format!("{count} pattern{} pasted", if count == 1 { "" } else { "s" }),
|
||||
150,
|
||||
FlashKind::Success,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shift_patterns_up(&mut self) {
|
||||
let bank = self.patterns_nav.bank_cursor;
|
||||
let patterns = self.patterns_nav.selected_patterns();
|
||||
let start = *patterns.first().unwrap();
|
||||
let end = *patterns.last().unwrap();
|
||||
if let Some(dirty) = clipboard::shift_patterns_up(
|
||||
&mut self.project_state.project,
|
||||
bank,
|
||||
start..=end,
|
||||
) {
|
||||
for (b, p) in &dirty {
|
||||
self.project_state.mark_dirty(*b, *p);
|
||||
}
|
||||
self.patterns_nav.pattern_cursor -= 1;
|
||||
if let Some(ref mut anchor) = self.patterns_nav.pattern_anchor {
|
||||
*anchor -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shift_patterns_down(&mut self) {
|
||||
let bank = self.patterns_nav.bank_cursor;
|
||||
let patterns = self.patterns_nav.selected_patterns();
|
||||
let start = *patterns.first().unwrap();
|
||||
let end = *patterns.last().unwrap();
|
||||
if let Some(dirty) = clipboard::shift_patterns_down(
|
||||
&mut self.project_state.project,
|
||||
bank,
|
||||
start..=end,
|
||||
) {
|
||||
for (b, p) in &dirty {
|
||||
self.project_state.mark_dirty(*b, *p);
|
||||
}
|
||||
self.patterns_nav.pattern_cursor += 1;
|
||||
if let Some(ref mut anchor) = self.patterns_nav.pattern_anchor {
|
||||
*anchor += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn copy_bank(&mut self, bank: usize) {
|
||||
self.copied_banks = Some(vec![clipboard::copy_bank(
|
||||
&self.project_state.project,
|
||||
bank,
|
||||
)]);
|
||||
self.ui.flash("Bank copied", 150, FlashKind::Success);
|
||||
}
|
||||
|
||||
pub fn paste_bank(&mut self, bank: usize) {
|
||||
if let Some(src) = self.copied_banks.as_ref().and_then(|v| v.first()) {
|
||||
let src = src.clone();
|
||||
let pat_count =
|
||||
clipboard::paste_bank(&mut self.project_state.project, bank, &src);
|
||||
for pattern in 0..pat_count {
|
||||
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 copy_banks(&mut self, banks: &[usize]) {
|
||||
self.copied_banks = Some(clipboard::copy_banks(
|
||||
&self.project_state.project,
|
||||
banks,
|
||||
));
|
||||
let n = banks.len();
|
||||
self.ui.flash(
|
||||
&format!("{n} bank{} copied", if n == 1 { "" } else { "s" }),
|
||||
150,
|
||||
FlashKind::Success,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn paste_banks(&mut self, start: usize) {
|
||||
if let Some(sources) = self.copied_banks.clone() {
|
||||
let count = clipboard::paste_banks(
|
||||
&mut self.project_state.project,
|
||||
start,
|
||||
&sources,
|
||||
);
|
||||
for i in 0..count {
|
||||
let bank = start + i;
|
||||
for pattern in 0..model::MAX_PATTERNS {
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
}
|
||||
}
|
||||
if (start..start + count).contains(&self.editor_ctx.bank) {
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
self.ui.flash(
|
||||
&format!("{count} bank{} pasted", if count == 1 { "" } else { "s" }),
|
||||
150,
|
||||
FlashKind::Success,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn harden_steps(&mut self) {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
let indices = self.selected_steps();
|
||||
let count = clipboard::harden_steps(&mut self.project_state.project, bank, pattern, &indices);
|
||||
if count == 0 {
|
||||
self.ui.set_status("No linked steps to harden".to_string());
|
||||
return;
|
||||
}
|
||||
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 indices = self.selected_steps();
|
||||
let (copied, scripts) = clipboard::copy_steps(
|
||||
&self.project_state.project,
|
||||
bank,
|
||||
pattern,
|
||||
&indices,
|
||||
);
|
||||
let count = copied.steps.len();
|
||||
self.editor_ctx.copied_steps = Some(copied);
|
||||
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: &crate::engine::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 cursor = self.editor_ctx.step;
|
||||
let result = clipboard::paste_steps(
|
||||
&mut self.project_state.project,
|
||||
bank,
|
||||
pattern,
|
||||
cursor,
|
||||
&copied,
|
||||
);
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
self.load_step_to_editor();
|
||||
for &target in &result.compile_targets {
|
||||
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!("Pasted {} steps", result.count),
|
||||
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();
|
||||
let cursor = self.editor_ctx.step;
|
||||
match clipboard::link_paste_steps(
|
||||
&mut self.project_state.project,
|
||||
bank,
|
||||
pattern,
|
||||
cursor,
|
||||
&copied,
|
||||
) {
|
||||
None => {
|
||||
self.ui
|
||||
.set_status("Can only link within same pattern".to_string());
|
||||
}
|
||||
Some(count) => {
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
self.load_step_to_editor();
|
||||
self.editor_ctx.clear_selection();
|
||||
self.ui.flash(
|
||||
&format!("Linked {count} steps"),
|
||||
150,
|
||||
FlashKind::Success,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn duplicate_steps(&mut self, link: &crate::engine::LinkState) {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
let indices = self.selected_steps();
|
||||
let result = clipboard::duplicate_steps(
|
||||
&mut self.project_state.project,
|
||||
bank,
|
||||
pattern,
|
||||
&indices,
|
||||
);
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
self.load_step_to_editor();
|
||||
for &target in &result.compile_targets {
|
||||
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 {} steps", result.count),
|
||||
150,
|
||||
FlashKind::Success,
|
||||
);
|
||||
}
|
||||
}
|
||||
442
src/app/dispatch.rs
Normal file
442
src/app/dispatch.rs
Normal file
@@ -0,0 +1,442 @@
|
||||
use crate::commands::AppCommand;
|
||||
use crate::engine::{LinkState, SequencerSnapshot};
|
||||
use crate::services::{dict_nav, euclidean, help_nav, pattern_editor};
|
||||
use crate::state::{undo::UndoEntry, CyclicEnum, FlashKind, Modal, StagedPropChange};
|
||||
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) {
|
||||
match cmd {
|
||||
AppCommand::Undo => {
|
||||
if let Some(entry) = self.undo.pop_undo() {
|
||||
let reverse = self.apply_undo_entry(entry);
|
||||
self.undo.push_redo(reverse);
|
||||
self.ui.flash("Undo", 100, FlashKind::Info);
|
||||
}
|
||||
return;
|
||||
}
|
||||
AppCommand::Redo => {
|
||||
if let Some(entry) = self.undo.pop_redo() {
|
||||
let reverse = self.apply_undo_entry(entry);
|
||||
self.undo.undo_stack.push(reverse);
|
||||
self.ui.flash("Redo", 100, FlashKind::Info);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some(scope) = self.undoable_scope(&cmd) {
|
||||
let cursor = (self.editor_ctx.bank, self.editor_ctx.pattern, self.editor_ctx.step);
|
||||
self.undo.push(UndoEntry { scope, cursor });
|
||||
}
|
||||
|
||||
match cmd {
|
||||
AppCommand::Undo | AppCommand::Redo => unreachable!(),
|
||||
|
||||
// Playback
|
||||
AppCommand::TogglePlaying => self.toggle_playing(link),
|
||||
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::CopyPatterns { bank, patterns } => self.copy_patterns(bank, &patterns),
|
||||
AppCommand::PastePatterns { bank, start } => self.paste_patterns(bank, start),
|
||||
AppCommand::CopyBank { bank } => self.copy_bank(bank),
|
||||
AppCommand::PasteBank { bank } => self.paste_bank(bank),
|
||||
AppCommand::CopyBanks { banks } => self.copy_banks(&banks),
|
||||
AppCommand::PasteBanks { start } => self.paste_banks(start),
|
||||
AppCommand::ResetPatterns { bank, patterns } => self.reset_patterns(bank, &patterns),
|
||||
AppCommand::ResetBanks { banks } => self.reset_banks(&banks),
|
||||
|
||||
// Reorder
|
||||
AppCommand::ShiftPatternsUp => self.shift_patterns_up(),
|
||||
AppCommand::ShiftPatternsDown => self.shift_patterns_down(),
|
||||
|
||||
// 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::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) {
|
||||
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 as usize;
|
||||
}
|
||||
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::StagePatternProps {
|
||||
bank,
|
||||
pattern,
|
||||
name,
|
||||
length,
|
||||
speed,
|
||||
quantization,
|
||||
sync_mode,
|
||||
} => {
|
||||
self.playback.staged_prop_changes.insert(
|
||||
(bank, pattern),
|
||||
StagedPropChange {
|
||||
name,
|
||||
length,
|
||||
speed,
|
||||
quantization,
|
||||
sync_mode,
|
||||
},
|
||||
);
|
||||
self.ui.set_status(format!(
|
||||
"B{:02}:P{:02} props staged",
|
||||
bank + 1,
|
||||
pattern + 1
|
||||
));
|
||||
}
|
||||
|
||||
// Page navigation
|
||||
AppCommand::PageLeft => {
|
||||
self.page.left();
|
||||
self.maybe_show_onboarding();
|
||||
}
|
||||
AppCommand::PageRight => {
|
||||
self.page.right();
|
||||
self.maybe_show_onboarding();
|
||||
}
|
||||
AppCommand::PageUp => {
|
||||
self.page.up();
|
||||
self.maybe_show_onboarding();
|
||||
}
|
||||
AppCommand::PageDown => {
|
||||
self.page.down();
|
||||
self.maybe_show_onboarding();
|
||||
}
|
||||
AppCommand::GoToPage(page) => {
|
||||
self.page = page;
|
||||
self.maybe_show_onboarding();
|
||||
}
|
||||
|
||||
// Help navigation
|
||||
AppCommand::HelpToggleFocus => help_nav::toggle_focus(&mut self.ui),
|
||||
AppCommand::HelpNextTopic(n) => help_nav::next_topic(&mut self.ui, n),
|
||||
AppCommand::HelpPrevTopic(n) => help_nav::prev_topic(&mut self.ui, n),
|
||||
AppCommand::HelpScrollDown(n) => help_nav::scroll_down(&mut self.ui, n),
|
||||
AppCommand::HelpScrollUp(n) => help_nav::scroll_up(&mut self.ui, n),
|
||||
AppCommand::HelpActivateSearch => help_nav::activate_search(&mut self.ui),
|
||||
AppCommand::HelpClearSearch => help_nav::clear_search(&mut self.ui),
|
||||
AppCommand::HelpSearchInput(c) => help_nav::search_input(&mut self.ui, c),
|
||||
AppCommand::HelpSearchBackspace => help_nav::search_backspace(&mut self.ui),
|
||||
AppCommand::HelpSearchConfirm => help_nav::search_confirm(&mut self.ui),
|
||||
|
||||
// Dictionary navigation
|
||||
AppCommand::DictToggleFocus => dict_nav::toggle_focus(&mut self.ui),
|
||||
AppCommand::DictNextCategory => dict_nav::next_category(&mut self.ui),
|
||||
AppCommand::DictPrevCategory => dict_nav::prev_category(&mut self.ui),
|
||||
AppCommand::DictScrollDown(n) => dict_nav::scroll_down(&mut self.ui, n),
|
||||
AppCommand::DictScrollUp(n) => dict_nav::scroll_up(&mut self.ui, n),
|
||||
AppCommand::DictActivateSearch => dict_nav::activate_search(&mut self.ui),
|
||||
AppCommand::DictClearSearch => dict_nav::clear_search(&mut self.ui),
|
||||
AppCommand::DictSearchInput(c) => dict_nav::search_input(&mut self.ui, c),
|
||||
AppCommand::DictSearchBackspace => dict_nav::search_backspace(&mut self.ui),
|
||||
AppCommand::DictSearchConfirm => dict_nav::search_confirm(&mut self.ui),
|
||||
|
||||
// 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();
|
||||
self.maybe_show_onboarding();
|
||||
}
|
||||
AppCommand::PatternsBack => {
|
||||
self.page.down();
|
||||
self.maybe_show_onboarding();
|
||||
}
|
||||
|
||||
// Mute/Solo (staged)
|
||||
AppCommand::StageMute { bank, pattern } => self.playback.stage_mute(bank, pattern),
|
||||
AppCommand::StageSolo { bank, pattern } => self.playback.stage_solo(bank, pattern),
|
||||
AppCommand::ClearMutes => {
|
||||
self.playback.clear_staged_mutes();
|
||||
self.mute.clear_mute();
|
||||
}
|
||||
AppCommand::ClearSolos => {
|
||||
self.playback.clear_staged_solos();
|
||||
self.mute.clear_solo();
|
||||
}
|
||||
|
||||
// UI state
|
||||
AppCommand::ClearMinimap => self.ui.dismiss_minimap(),
|
||||
AppCommand::HideTitle => {
|
||||
self.ui.show_title = false;
|
||||
self.maybe_show_onboarding();
|
||||
}
|
||||
AppCommand::ToggleEditorStack => {
|
||||
self.editor_ctx.show_stack = !self.editor_ctx.show_stack;
|
||||
if self.editor_ctx.show_stack {
|
||||
crate::services::stack_preview::update_cache(&self.editor_ctx);
|
||||
}
|
||||
}
|
||||
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::ToggleLiveKeysFill => self.live_keys.flip_fill(),
|
||||
|
||||
// Panel
|
||||
AppCommand::ClosePanel => {
|
||||
self.panel.visible = false;
|
||||
self.panel.focus = crate::state::PanelFocus::Main;
|
||||
}
|
||||
|
||||
// Direct navigation (mouse)
|
||||
AppCommand::GoToStep(step) => {
|
||||
let len = self.current_edit_pattern().length;
|
||||
if step < len {
|
||||
self.editor_ctx.step = step;
|
||||
self.editor_ctx.clear_selection();
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
}
|
||||
AppCommand::PatternsSelectBank(bank) => {
|
||||
self.patterns_nav.bank_cursor = bank;
|
||||
self.patterns_nav.clear_selection();
|
||||
}
|
||||
AppCommand::PatternsSelectPattern(pattern) => {
|
||||
self.patterns_nav.pattern_cursor = pattern;
|
||||
self.patterns_nav.clear_selection();
|
||||
}
|
||||
AppCommand::HelpSelectTopic(i) => help_nav::select_topic(&mut self.ui, i),
|
||||
AppCommand::DictSelectCategory(i) => dict_nav::select_category(&mut self.ui, i),
|
||||
|
||||
// Selection
|
||||
AppCommand::SetSelectionAnchor(step) => {
|
||||
self.editor_ctx.selection_anchor = Some(step);
|
||||
}
|
||||
|
||||
// Audio settings (engine page)
|
||||
AppCommand::AudioSetSection(section) => self.audio.section = section,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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::OptionsSetFocus(focus) => self.options.focus = 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,
|
||||
AppCommand::TogglePreview => self.audio.config.show_preview = !self.audio.config.show_preview,
|
||||
|
||||
// Metrics
|
||||
AppCommand::ResetPeakVoices => self.metrics.peak_voices = 0,
|
||||
|
||||
// Euclidean distribution
|
||||
AppCommand::ApplyEuclideanDistribution {
|
||||
bank,
|
||||
pattern,
|
||||
source_step,
|
||||
pulses,
|
||||
steps,
|
||||
rotation,
|
||||
} => {
|
||||
let targets = euclidean::apply_distribution(
|
||||
&mut self.project_state.project,
|
||||
bank,
|
||||
pattern,
|
||||
source_step,
|
||||
pulses,
|
||||
steps,
|
||||
rotation,
|
||||
);
|
||||
let created_count = targets.len();
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
for &target in &targets {
|
||||
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 {created_count} linked steps (E({pulses},{steps}))"),
|
||||
200,
|
||||
FlashKind::Success,
|
||||
);
|
||||
}
|
||||
|
||||
// Onboarding
|
||||
AppCommand::DismissOnboarding => {
|
||||
let name = self.page.name().to_string();
|
||||
if !self.ui.onboarding_dismissed.contains(&name) {
|
||||
self.ui.onboarding_dismissed.push(name);
|
||||
}
|
||||
}
|
||||
AppCommand::ResetOnboarding => self.ui.onboarding_dismissed.clear(),
|
||||
AppCommand::GoToHelpTopic(topic) => {
|
||||
self.ui.modal = Modal::None;
|
||||
self.page = crate::page::Page::Help;
|
||||
self.ui.help_topic = topic;
|
||||
}
|
||||
|
||||
// Prelude
|
||||
AppCommand::OpenPreludeEditor => self.open_prelude_editor(),
|
||||
AppCommand::SavePrelude => self.save_prelude(),
|
||||
AppCommand::EvaluatePrelude => self.evaluate_prelude(link),
|
||||
AppCommand::ClosePreludeEditor => self.close_prelude_editor(),
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/app/editing.rs
Normal file
126
src/app/editing.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use crate::services::pattern_editor;
|
||||
use crate::state::FlashKind;
|
||||
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
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);
|
||||
}
|
||||
|
||||
pub fn delete_step(&mut self, bank: usize, pattern: usize, step: usize) {
|
||||
let edit = pattern_editor::delete_step(&mut self.project_state.project, bank, pattern, step);
|
||||
self.project_state.mark_dirty(edit.bank, edit.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]) {
|
||||
let edit = pattern_editor::delete_steps(&mut self.project_state.project, bank, pattern, steps);
|
||||
self.project_state.mark_dirty(edit.bank, edit.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) {
|
||||
let edit = pattern_editor::reset_pattern(&mut self.project_state.project, bank, pattern);
|
||||
self.project_state.mark_dirty(edit.bank, edit.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) {
|
||||
let pat_count = pattern_editor::reset_bank(&mut self.project_state.project, bank);
|
||||
for pattern in 0..pat_count {
|
||||
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 reset_patterns(&mut self, bank: usize, patterns: &[usize]) {
|
||||
for &pattern in patterns {
|
||||
let edit =
|
||||
pattern_editor::reset_pattern(&mut self.project_state.project, bank, pattern);
|
||||
self.project_state.mark_dirty(edit.bank, edit.pattern);
|
||||
}
|
||||
if self.editor_ctx.bank == bank && patterns.contains(&self.editor_ctx.pattern) {
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
let n = patterns.len();
|
||||
self.ui.flash(
|
||||
&format!("{n} pattern{} reset", if n == 1 { "" } else { "s" }),
|
||||
150,
|
||||
FlashKind::Success,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn reset_banks(&mut self, banks: &[usize]) {
|
||||
for &bank in banks {
|
||||
let pat_count =
|
||||
pattern_editor::reset_bank(&mut self.project_state.project, bank);
|
||||
for pattern in 0..pat_count {
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
}
|
||||
}
|
||||
if banks.contains(&self.editor_ctx.bank) {
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
let n = banks.len();
|
||||
self.ui.flash(
|
||||
&format!("{n} bank{} reset", if n == 1 { "" } else { "s" }),
|
||||
150,
|
||||
FlashKind::Success,
|
||||
);
|
||||
}
|
||||
}
|
||||
192
src/app/mod.rs
Normal file
192
src/app/mod.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
mod clipboard;
|
||||
mod dispatch;
|
||||
mod editing;
|
||||
mod navigation;
|
||||
mod persistence;
|
||||
mod scripting;
|
||||
mod sequencer;
|
||||
mod staging;
|
||||
mod undo;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use parking_lot::Mutex;
|
||||
use rand::rngs::StdRng;
|
||||
use rand::SeedableRng;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use cagire_ratatui::CompletionCandidate;
|
||||
|
||||
use crate::engine::LinkState;
|
||||
use crate::midi::MidiState;
|
||||
use crate::model::{self, Bank, Dictionary, Pattern, Rng, ScriptEngine, Variables};
|
||||
use crate::page::Page;
|
||||
use crate::state::{
|
||||
undo::UndoHistory, AudioSettings, EditorContext, LiveKeyState, Metrics, Modal, MuteState,
|
||||
OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, PlaybackState,
|
||||
ProjectState, UiState,
|
||||
};
|
||||
|
||||
const STEPS_PER_PAGE: usize = 32;
|
||||
|
||||
static COMPLETION_CANDIDATES: LazyLock<Vec<CompletionCandidate>> = LazyLock::new(|| {
|
||||
model::WORDS
|
||||
.iter()
|
||||
.map(|w| CompletionCandidate {
|
||||
name: w.name.to_string(),
|
||||
signature: w.stack.to_string(),
|
||||
description: w.desc.to_string(),
|
||||
example: w.example.to_string(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
pub struct App {
|
||||
pub project_state: ProjectState,
|
||||
pub ui: UiState,
|
||||
pub playback: PlaybackState,
|
||||
pub mute: MuteState,
|
||||
|
||||
pub page: Page,
|
||||
pub editor_ctx: EditorContext,
|
||||
|
||||
pub patterns_nav: PatternsNav,
|
||||
|
||||
pub metrics: Metrics,
|
||||
pub script_engine: ScriptEngine,
|
||||
pub variables: Variables,
|
||||
pub dict: Dictionary,
|
||||
#[allow(dead_code)]
|
||||
pub rng: Rng,
|
||||
pub live_keys: Arc<LiveKeyState>,
|
||||
pub clipboard: Option<arboard::Clipboard>,
|
||||
pub copied_patterns: Option<Vec<Pattern>>,
|
||||
pub copied_banks: Option<Vec<Bank>>,
|
||||
|
||||
pub undo: UndoHistory,
|
||||
|
||||
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(ArcSwap::from_pointee(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(),
|
||||
mute: MuteState::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_patterns: None,
|
||||
copied_banks: None,
|
||||
|
||||
undo: UndoHistory::default(),
|
||||
|
||||
audio: AudioSettings::default(),
|
||||
options: OptionsState::default(),
|
||||
panel: PanelState::default(),
|
||||
midi: MidiState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
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],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_all_patterns_dirty(&mut self) {
|
||||
self.project_state.mark_all_dirty();
|
||||
}
|
||||
|
||||
pub fn toggle_playing(&mut self, link: &LinkState) {
|
||||
let was_playing = self.playback.playing;
|
||||
self.playback.toggle();
|
||||
if !was_playing && self.playback.playing {
|
||||
self.evaluate_prelude(link);
|
||||
}
|
||||
}
|
||||
|
||||
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 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 maybe_show_onboarding(&mut self) {
|
||||
if self.ui.modal != Modal::None {
|
||||
return;
|
||||
}
|
||||
let name = self.page.name();
|
||||
if self.ui.onboarding_dismissed.iter().any(|d| d == name) {
|
||||
return;
|
||||
}
|
||||
self.ui.modal = Modal::Onboarding { page: 0 };
|
||||
}
|
||||
}
|
||||
54
src/app/navigation.rs
Normal file
54
src/app/navigation.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use super::{App, STEPS_PER_PAGE};
|
||||
|
||||
impl App {
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
125
src/app/persistence.rs
Normal file
125
src/app/persistence.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::engine::{LinkState, PatternChange, SequencerSnapshot};
|
||||
use crate::model;
|
||||
use crate::settings::Settings;
|
||||
use crate::state::StagedChange;
|
||||
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
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,
|
||||
},
|
||||
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_preview: self.audio.config.show_preview,
|
||||
show_completion: self.ui.show_completion,
|
||||
color_scheme: self.ui.color_scheme,
|
||||
layout: self.audio.config.layout,
|
||||
hue_rotation: self.ui.hue_rotation,
|
||||
onboarding_dismissed: self.ui.onboarding_dismissed.clone(),
|
||||
..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();
|
||||
}
|
||||
|
||||
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.undo.clear();
|
||||
self.variables.store(Arc::new(HashMap::new()));
|
||||
self.dict.lock().clear();
|
||||
|
||||
self.evaluate_prelude(link);
|
||||
|
||||
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::PhaseLock,
|
||||
});
|
||||
}
|
||||
|
||||
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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
190
src/app/scripting.rs
Normal file
190
src/app/scripting.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
use crossbeam_channel::Sender;
|
||||
|
||||
use crate::engine::LinkState;
|
||||
use crate::model::StepContext;
|
||||
use crate::services::pattern_editor;
|
||||
use crate::state::{EditorTarget, FlashKind, Modal, SampleTree};
|
||||
|
||||
use super::{App, COMPLETION_CANDIDATES};
|
||||
|
||||
impl App {
|
||||
pub(super) fn create_step_context(&self, step_idx: usize, link: &LinkState) -> StepContext<'static> {
|
||||
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,
|
||||
speed_key: "",
|
||||
chain_key: "",
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_x: 0.5,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_y: 0.5,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_down: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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);
|
||||
self.editor_ctx.editor.set_candidates(COMPLETION_CANDIDATES.clone());
|
||||
self.editor_ctx
|
||||
.editor
|
||||
.set_completion_enabled(self.ui.show_completion);
|
||||
let tree = SampleTree::from_paths(&self.audio.config.sample_paths);
|
||||
self.editor_ctx.editor.set_sample_folders(tree.all_folder_names());
|
||||
if self.editor_ctx.show_stack {
|
||||
crate::services::stack_preview::update_cache(&self.editor_ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 open_prelude_editor(&mut self) {
|
||||
let prelude = &self.project_state.project.prelude;
|
||||
let lines: Vec<String> = if prelude.is_empty() {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
prelude.lines().map(String::from).collect()
|
||||
};
|
||||
self.editor_ctx.editor.set_content(lines);
|
||||
self.editor_ctx.editor.set_candidates(COMPLETION_CANDIDATES.clone());
|
||||
self.editor_ctx
|
||||
.editor
|
||||
.set_completion_enabled(self.ui.show_completion);
|
||||
let tree = SampleTree::from_paths(&self.audio.config.sample_paths);
|
||||
self.editor_ctx.editor.set_sample_folders(tree.all_folder_names());
|
||||
self.editor_ctx.target = EditorTarget::Prelude;
|
||||
self.ui.modal = Modal::Editor;
|
||||
}
|
||||
|
||||
pub fn save_prelude(&mut self) {
|
||||
let text = self.editor_ctx.editor.content();
|
||||
self.project_state.project.prelude = text;
|
||||
}
|
||||
|
||||
pub fn close_prelude_editor(&mut self) {
|
||||
self.editor_ctx.target = EditorTarget::Step;
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
|
||||
pub fn evaluate_prelude(&mut self, link: &LinkState) {
|
||||
let prelude = &self.project_state.project.prelude;
|
||||
if prelude.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let ctx = self.create_step_context(0, link);
|
||||
match self.script_engine.evaluate(prelude, &ctx) {
|
||||
Ok(_) => {
|
||||
self.ui.flash("Prelude evaluated", 150, FlashKind::Info);
|
||||
}
|
||||
Err(e) => {
|
||||
self.ui
|
||||
.flash(&format!("Prelude error: {e}"), 300, FlashKind::Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
return;
|
||||
}
|
||||
|
||||
let ctx = self.create_step_context(step_idx, link);
|
||||
|
||||
match self.script_engine.evaluate(&script, &ctx) {
|
||||
Ok(_) => {
|
||||
self.ui.flash("Script compiled", 150, FlashKind::Info);
|
||||
}
|
||||
Err(e) => {
|
||||
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() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ctx = self.create_step_context(step_idx, link);
|
||||
let _ = self.script_engine.evaluate(&script, &ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/app/sequencer.rs
Normal file
63
src/app/sequencer.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use crossbeam_channel::Sender;
|
||||
|
||||
use crate::engine::{PatternChange, PatternSnapshot, SeqCommand, StepSnapshot};
|
||||
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
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 send_mute_state(&self, cmd_tx: &Sender<SeqCommand>) {
|
||||
let _ = cmd_tx.send(SeqCommand::SetMuteState {
|
||||
muted: self.mute.muted.clone(),
|
||||
soloed: self.mute.soloed.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
115
src/app/staging.rs
Normal file
115
src/app/staging.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use crate::engine::{PatternChange, SequencerSnapshot};
|
||||
use crate::state::StagedChange;
|
||||
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits staged pattern, mute/solo, and prop changes.
|
||||
/// Returns true if mute state changed (caller should send to sequencer).
|
||||
pub fn commit_staged_changes(&mut self) -> bool {
|
||||
let pattern_count = self.playback.staged_changes.len();
|
||||
let mute_count = self.playback.staged_mute_changes.len();
|
||||
let prop_count = self.playback.staged_prop_changes.len();
|
||||
|
||||
if pattern_count == 0 && mute_count == 0 && prop_count == 0 {
|
||||
self.ui.set_status("No changes to commit".to_string());
|
||||
return false;
|
||||
}
|
||||
|
||||
if pattern_count > 0 {
|
||||
self.playback
|
||||
.queued_changes
|
||||
.append(&mut self.playback.staged_changes);
|
||||
}
|
||||
|
||||
let mute_changed = mute_count > 0;
|
||||
for change in self.playback.staged_mute_changes.drain() {
|
||||
match change {
|
||||
crate::state::StagedMuteChange::ToggleMute { bank, pattern } => {
|
||||
self.mute.toggle_mute(bank, pattern);
|
||||
}
|
||||
crate::state::StagedMuteChange::ToggleSolo { bank, pattern } => {
|
||||
self.mute.toggle_solo(bank, pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for ((bank, pattern), props) in self.playback.staged_prop_changes.drain() {
|
||||
let pat = self.project_state.project.pattern_at_mut(bank, pattern);
|
||||
pat.name = props.name;
|
||||
if let Some(len) = props.length {
|
||||
pat.set_length(len);
|
||||
}
|
||||
pat.speed = props.speed;
|
||||
pat.quantization = props.quantization;
|
||||
pat.sync_mode = props.sync_mode;
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
}
|
||||
|
||||
let total = pattern_count + mute_count + prop_count;
|
||||
let status = format!("Committed {total} changes");
|
||||
self.ui.set_status(status);
|
||||
|
||||
mute_changed
|
||||
}
|
||||
|
||||
pub fn clear_staged_changes(&mut self) {
|
||||
let pattern_count = self.playback.staged_changes.len();
|
||||
let mute_count = self.playback.staged_mute_changes.len();
|
||||
let prop_count = self.playback.staged_prop_changes.len();
|
||||
|
||||
if pattern_count == 0 && mute_count == 0 && prop_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
self.playback.staged_changes.clear();
|
||||
self.playback.staged_mute_changes.clear();
|
||||
self.playback.staged_prop_changes.clear();
|
||||
|
||||
let total = pattern_count + mute_count + prop_count;
|
||||
let status = format!("Cleared {total} staged changes");
|
||||
self.ui.set_status(status);
|
||||
}
|
||||
}
|
||||
88
src/app/undo.rs
Normal file
88
src/app/undo.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use crate::commands::AppCommand;
|
||||
use crate::state::undo::{UndoEntry, UndoScope};
|
||||
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
pub(super) fn undoable_scope(&self, cmd: &AppCommand) -> Option<UndoScope> {
|
||||
match cmd {
|
||||
AppCommand::ToggleSteps
|
||||
| AppCommand::LengthIncrease
|
||||
| AppCommand::LengthDecrease
|
||||
| AppCommand::SpeedIncrease
|
||||
| AppCommand::SpeedDecrease
|
||||
| AppCommand::SaveEditorToStep
|
||||
| AppCommand::CompileCurrentStep
|
||||
| AppCommand::HardenSteps
|
||||
| AppCommand::PasteSteps
|
||||
| AppCommand::LinkPasteSteps
|
||||
| AppCommand::DuplicateSteps
|
||||
| AppCommand::CopySteps => {
|
||||
let (bank, pattern) = self.current_bank_pattern();
|
||||
let data = self.project_state.project.pattern_at(bank, pattern).clone();
|
||||
Some(UndoScope::Pattern { bank, pattern, data })
|
||||
}
|
||||
AppCommand::SetLength { bank, pattern, .. }
|
||||
| AppCommand::SetSpeed { bank, pattern, .. }
|
||||
| AppCommand::DeleteStep { bank, pattern, .. }
|
||||
| AppCommand::DeleteSteps { bank, pattern, .. }
|
||||
| AppCommand::ResetPattern { bank, pattern }
|
||||
| AppCommand::PastePattern { bank, pattern }
|
||||
| AppCommand::RenamePattern { bank, pattern, .. } => {
|
||||
let data = self.project_state.project.pattern_at(*bank, *pattern).clone();
|
||||
Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data })
|
||||
}
|
||||
AppCommand::RenameStep { bank, pattern, .. } => {
|
||||
let data = self.project_state.project.pattern_at(*bank, *pattern).clone();
|
||||
Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data })
|
||||
}
|
||||
AppCommand::ApplyEuclideanDistribution { bank, pattern, .. } => {
|
||||
let data = self.project_state.project.pattern_at(*bank, *pattern).clone();
|
||||
Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data })
|
||||
}
|
||||
AppCommand::ResetBank { bank } | AppCommand::PasteBank { bank } => {
|
||||
let data = self.project_state.project.banks[*bank].clone();
|
||||
Some(UndoScope::Bank { bank: *bank, data })
|
||||
}
|
||||
AppCommand::ShiftPatternsUp | AppCommand::ShiftPatternsDown => {
|
||||
let bank = self.patterns_nav.bank_cursor;
|
||||
let data = self.project_state.project.banks[bank].clone();
|
||||
Some(UndoScope::Bank { bank, data })
|
||||
}
|
||||
AppCommand::PastePatterns { bank, .. }
|
||||
| AppCommand::ResetPatterns { bank, .. }
|
||||
| AppCommand::RenameBank { bank, .. } => {
|
||||
let data = self.project_state.project.banks[*bank].clone();
|
||||
Some(UndoScope::Bank { bank: *bank, data })
|
||||
}
|
||||
AppCommand::PasteBanks { .. } | AppCommand::ResetBanks { .. } => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_undo_entry(&mut self, entry: UndoEntry) -> UndoEntry {
|
||||
let cursor = (self.editor_ctx.bank, self.editor_ctx.pattern, self.editor_ctx.step);
|
||||
let reverse_scope = match entry.scope {
|
||||
UndoScope::Pattern { bank, pattern, data } => {
|
||||
let current = self.project_state.project.pattern_at(bank, pattern).clone();
|
||||
*self.project_state.project.pattern_at_mut(bank, pattern) = data;
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
UndoScope::Pattern { bank, pattern, data: current }
|
||||
}
|
||||
UndoScope::Bank { bank, data } => {
|
||||
let current = self.project_state.project.banks[bank].clone();
|
||||
let pat_count = current.patterns.len();
|
||||
self.project_state.project.banks[bank] = data;
|
||||
for p in 0..pat_count {
|
||||
self.project_state.mark_dirty(bank, p);
|
||||
}
|
||||
UndoScope::Bank { bank, data: current }
|
||||
}
|
||||
};
|
||||
self.editor_ctx.bank = entry.cursor.0;
|
||||
self.editor_ctx.pattern = entry.cursor.1;
|
||||
self.editor_ctx.step = entry.cursor.2;
|
||||
self.load_step_to_editor();
|
||||
UndoEntry { scope: reverse_scope, cursor }
|
||||
}
|
||||
}
|
||||
@@ -526,7 +526,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
||||
}
|
||||
}
|
||||
Modal::Onboarding { .. } => {
|
||||
let pages = ctx.app.page.onboarding();
|
||||
let pages = crate::model::onboarding::for_page(ctx.app.page);
|
||||
let page_count = pages.len();
|
||||
match key.code {
|
||||
KeyCode::Right | KeyCode::Char('l') if page_count > 1 => {
|
||||
|
||||
@@ -9,67 +9,106 @@ pub const DOCS: &[DocEntry] = &[
|
||||
// Getting Started
|
||||
Section("Getting Started"),
|
||||
Topic("Welcome", include_str!("../../docs/welcome.md")),
|
||||
Topic("Moving Around", include_str!("../../docs/navigation.md")),
|
||||
Topic(
|
||||
"Moving Around",
|
||||
include_str!("../../docs/getting-started/navigation.md"),
|
||||
),
|
||||
Topic(
|
||||
"How Does It Work?",
|
||||
include_str!("../../docs/how_it_works.md"),
|
||||
include_str!("../../docs/getting-started/how_it_works.md"),
|
||||
),
|
||||
Topic(
|
||||
"Banks & Patterns",
|
||||
include_str!("../../docs/banks_patterns.md"),
|
||||
include_str!("../../docs/getting-started/banks_patterns.md"),
|
||||
),
|
||||
Topic(
|
||||
"Stage / Commit",
|
||||
include_str!("../../docs/getting-started/staging.md"),
|
||||
),
|
||||
Topic(
|
||||
"Using the Sequencer",
|
||||
include_str!("../../docs/getting-started/grid.md"),
|
||||
),
|
||||
Topic(
|
||||
"Editing a Step",
|
||||
include_str!("../../docs/getting-started/editing.md"),
|
||||
),
|
||||
Topic("Stage / Commit", include_str!("../../docs/staging.md")),
|
||||
Topic("Using the Sequencer", include_str!("../../docs/grid.md")),
|
||||
Topic("Editing a Step", include_str!("../../docs/editing.md")),
|
||||
// Forth fundamentals
|
||||
Section("Forth"),
|
||||
Topic("About Forth", include_str!("../../docs/about_forth.md")),
|
||||
Topic("The Dictionary", include_str!("../../docs/dictionary.md")),
|
||||
Topic("The Stack", include_str!("../../docs/stack.md")),
|
||||
Topic("Creating Words", include_str!("../../docs/definitions.md")),
|
||||
Topic("Control Flow", include_str!("../../docs/control_flow.md")),
|
||||
Topic("The Prelude", include_str!("../../docs/prelude.md")),
|
||||
Topic("Oddities", include_str!("../../docs/oddities.md")),
|
||||
Topic(
|
||||
"About Forth",
|
||||
include_str!("../../docs/forth/about_forth.md"),
|
||||
),
|
||||
Topic(
|
||||
"The Dictionary",
|
||||
include_str!("../../docs/forth/dictionary.md"),
|
||||
),
|
||||
Topic("The Stack", include_str!("../../docs/forth/stack.md")),
|
||||
Topic(
|
||||
"Creating Words",
|
||||
include_str!("../../docs/forth/definitions.md"),
|
||||
),
|
||||
Topic(
|
||||
"Control Flow",
|
||||
include_str!("../../docs/forth/control_flow.md"),
|
||||
),
|
||||
Topic("The Prelude", include_str!("../../docs/forth/prelude.md")),
|
||||
Topic("Oddities", include_str!("../../docs/forth/oddities.md")),
|
||||
// Audio Engine
|
||||
Section("Audio Engine"),
|
||||
Topic("Introduction", include_str!("../../docs/engine_intro.md")),
|
||||
Topic("Settings", include_str!("../../docs/engine_settings.md")),
|
||||
Topic("Sources", include_str!("../../docs/engine_sources.md")),
|
||||
Topic("Samples", include_str!("../../docs/engine_samples.md")),
|
||||
Topic("Wavetables", include_str!("../../docs/engine_wavetable.md")),
|
||||
Topic("Filters", include_str!("../../docs/engine_filters.md")),
|
||||
Topic("Introduction", include_str!("../../docs/engine/intro.md")),
|
||||
Topic("Settings", include_str!("../../docs/engine/settings.md")),
|
||||
Topic("Sources", include_str!("../../docs/engine/sources.md")),
|
||||
Topic("Samples", include_str!("../../docs/engine/samples.md")),
|
||||
Topic(
|
||||
"Wavetables",
|
||||
include_str!("../../docs/engine/wavetable.md"),
|
||||
),
|
||||
Topic("Filters", include_str!("../../docs/engine/filters.md")),
|
||||
Topic(
|
||||
"Modulation",
|
||||
include_str!("../../docs/engine_modulation.md"),
|
||||
include_str!("../../docs/engine/modulation.md"),
|
||||
),
|
||||
Topic(
|
||||
"Distortion",
|
||||
include_str!("../../docs/engine_distortion.md"),
|
||||
include_str!("../../docs/engine/distortion.md"),
|
||||
),
|
||||
Topic("Space & Time", include_str!("../../docs/engine_space.md")),
|
||||
Topic("Space & Time", include_str!("../../docs/engine/space.md")),
|
||||
Topic(
|
||||
"Audio-Rate Mod",
|
||||
include_str!("../../docs/engine_audio_modulation.md"),
|
||||
include_str!("../../docs/engine/audio_modulation.md"),
|
||||
),
|
||||
Topic(
|
||||
"Words & Sounds",
|
||||
include_str!("../../docs/engine/words.md"),
|
||||
),
|
||||
Topic("Words & Sounds", include_str!("../../docs/engine_words.md")),
|
||||
// MIDI
|
||||
Section("MIDI"),
|
||||
Topic("Introduction", include_str!("../../docs/midi_intro.md")),
|
||||
Topic("MIDI Output", include_str!("../../docs/midi_output.md")),
|
||||
Topic("MIDI Input", include_str!("../../docs/midi_input.md")),
|
||||
Topic("Introduction", include_str!("../../docs/midi/intro.md")),
|
||||
Topic("MIDI Output", include_str!("../../docs/midi/output.md")),
|
||||
Topic("MIDI Input", include_str!("../../docs/midi/input.md")),
|
||||
// Tutorials
|
||||
Section("Tutorials"),
|
||||
Topic("Randomness", include_str!("../../docs/tutorial_randomness.md")),
|
||||
Topic(
|
||||
"Randomness",
|
||||
include_str!("../../docs/tutorials/randomness.md"),
|
||||
),
|
||||
Topic(
|
||||
"Notes & Harmony",
|
||||
include_str!("../../docs/tutorial_harmony.md"),
|
||||
include_str!("../../docs/tutorials/harmony.md"),
|
||||
),
|
||||
Topic(
|
||||
"Generators",
|
||||
include_str!("../../docs/tutorial_generators.md"),
|
||||
include_str!("../../docs/tutorials/generators.md"),
|
||||
),
|
||||
Topic(
|
||||
"Timing with at",
|
||||
include_str!("../../docs/tutorials/at.md"),
|
||||
),
|
||||
Topic(
|
||||
"Using Variables",
|
||||
include_str!("../../docs/tutorials/variables.md"),
|
||||
),
|
||||
Topic("Timing with at", include_str!("../../docs/tutorial_at.md")),
|
||||
Topic("Using Variables", include_str!("../../docs/tutorial_variables.md")),
|
||||
];
|
||||
|
||||
pub fn topic_count() -> usize {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod categories;
|
||||
pub mod docs;
|
||||
pub mod onboarding;
|
||||
mod script;
|
||||
|
||||
pub use cagire_forth::{
|
||||
|
||||
96
src/model/onboarding.rs
Normal file
96
src/model/onboarding.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use crate::page::Page;
|
||||
|
||||
pub fn for_page(page: Page) -> &'static [(&'static str, &'static [(&'static str, &'static str)])] {
|
||||
match page {
|
||||
Page::Main => &[
|
||||
(
|
||||
"The step sequencer grid. Each cell is a Forth script that produces sound when evaluated. During playback, active steps run left-to-right, top-to-bottom. Toggle steps on/off with t to build your pattern. The left panel shows playing patterns, the right side shows VU meters.",
|
||||
&[
|
||||
("Arrows", "navigate grid"),
|
||||
("Space", "play / stop"),
|
||||
("Enter", "edit step script"),
|
||||
("t", "toggle step on/off"),
|
||||
("p", "preview script"),
|
||||
("Tab", "sample browser"),
|
||||
("?", "all keybindings"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Enter opens the script editor (Esc saves and closes). Select ranges with Shift+arrows for bulk operations. Linked steps share one script: edit the source and all links update. Adjust pattern length/speed directly, or use euclidean distribution to spread a step rhythmically.",
|
||||
&[
|
||||
("Shift+Arrows", "select range"),
|
||||
("Ctrl+C / V", "copy / paste steps"),
|
||||
("Ctrl+D", "duplicate steps"),
|
||||
("Ctrl+B", "paste as linked copies"),
|
||||
("< > / [ ]", "length / speed"),
|
||||
("e", "euclidean distribution"),
|
||||
("+ - / T", "tempo adjust / set"),
|
||||
],
|
||||
),
|
||||
],
|
||||
Page::Patterns => &[
|
||||
(
|
||||
"Organize your project into banks and patterns. The left column lists 32 banks, the right shows patterns in the selected bank. Stage patterns to play or stop, then commit to apply all changes at once.",
|
||||
&[
|
||||
("Arrows", "navigate"),
|
||||
("Enter", "open in sequencer"),
|
||||
("Space", "stage play/stop"),
|
||||
("c", "commit changes"),
|
||||
("r", "rename"),
|
||||
("e", "properties"),
|
||||
("?", "all keys"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Mute and solo patterns to control the mix. Use euclidean distribution to generate rhythmic patterns from a single step. Select multiple patterns with Shift for bulk operations.",
|
||||
&[
|
||||
("m", "stage mute"),
|
||||
("s", "stage solo"),
|
||||
("E", "euclidean"),
|
||||
("Shift+↑↓", "select range"),
|
||||
("y", "copy"),
|
||||
("P", "paste"),
|
||||
],
|
||||
),
|
||||
],
|
||||
Page::Engine => &[(
|
||||
"Audio engine configuration. Select output and input devices, adjust buffer size and polyphony, and manage sample directories. The right side shows a live scope and spectrum analyzer.",
|
||||
&[
|
||||
("Tab", "switch section"),
|
||||
("↑↓", "navigate"),
|
||||
("←→", "adjust"),
|
||||
("R", "restart engine"),
|
||||
("A", "add samples"),
|
||||
("?", "all keys"),
|
||||
],
|
||||
)],
|
||||
Page::Options => &[(
|
||||
"Global settings for display, UI, Link sync, MIDI, etc. All changes save automatically. Tutorial can be reset from here!",
|
||||
&[
|
||||
("↑↓", "navigate"),
|
||||
("←→", "change value"),
|
||||
("?", "all keys"),
|
||||
],
|
||||
)],
|
||||
Page::Help => &[(
|
||||
"Interactive documentation with executable Forth examples. Browse topics on the left, read content on the right. Code blocks can be run directly and evaluated by the sequencer engine.",
|
||||
&[
|
||||
("Tab", "switch panels"),
|
||||
("↑↓", "navigate"),
|
||||
("Enter", "run code block"),
|
||||
("n/p", "next/prev example"),
|
||||
("/", "search"),
|
||||
("?", "all keys"),
|
||||
],
|
||||
)],
|
||||
Page::Dict => &[(
|
||||
"Complete reference of all Forth words by category. Each entry shows the word name, stack effect signature, description, and a usage example. Search filters across all categories.",
|
||||
&[
|
||||
("Tab", "switch panels"),
|
||||
("↑↓", "navigate"),
|
||||
("/", "search"),
|
||||
("?", "all keys"),
|
||||
],
|
||||
)],
|
||||
}
|
||||
}
|
||||
95
src/page.rs
95
src/page.rs
@@ -92,101 +92,6 @@ impl Page {
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn onboarding(self) -> &'static [(&'static str, &'static [(&'static str, &'static str)])] {
|
||||
match self {
|
||||
Page::Main => &[
|
||||
(
|
||||
"The step sequencer grid. Each cell is a Forth script that produces sound when evaluated. During playback, active steps run left-to-right, top-to-bottom. Toggle steps on/off with t to build your pattern. The left panel shows playing patterns, the right side shows VU meters.",
|
||||
&[
|
||||
("Arrows", "navigate grid"),
|
||||
("Space", "play / stop"),
|
||||
("Enter", "edit step script"),
|
||||
("t", "toggle step on/off"),
|
||||
("p", "preview script"),
|
||||
("Tab", "sample browser"),
|
||||
("?", "all keybindings"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Enter opens the script editor (Esc saves and closes). Select ranges with Shift+arrows for bulk operations. Linked steps share one script: edit the source and all links update. Adjust pattern length/speed directly, or use euclidean distribution to spread a step rhythmically.",
|
||||
&[
|
||||
("Shift+Arrows", "select range"),
|
||||
("Ctrl+C / V", "copy / paste steps"),
|
||||
("Ctrl+D", "duplicate steps"),
|
||||
("Ctrl+B", "paste as linked copies"),
|
||||
("< > / [ ]", "length / speed"),
|
||||
("e", "euclidean distribution"),
|
||||
("+ - / T", "tempo adjust / set"),
|
||||
],
|
||||
),
|
||||
],
|
||||
Page::Patterns => &[
|
||||
(
|
||||
"Organize your project into banks and patterns. The left column lists 32 banks, the right shows patterns in the selected bank. Stage patterns to play or stop, then commit to apply all changes at once.",
|
||||
&[
|
||||
("Arrows", "navigate"),
|
||||
("Enter", "open in sequencer"),
|
||||
("Space", "stage play/stop"),
|
||||
("c", "commit changes"),
|
||||
("r", "rename"),
|
||||
("e", "properties"),
|
||||
("?", "all keys"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Mute and solo patterns to control the mix. Use euclidean distribution to generate rhythmic patterns from a single step. Select multiple patterns with Shift for bulk operations.",
|
||||
&[
|
||||
("m", "stage mute"),
|
||||
("s", "stage solo"),
|
||||
("E", "euclidean"),
|
||||
("Shift+↑↓", "select range"),
|
||||
("y", "copy"),
|
||||
("P", "paste"),
|
||||
],
|
||||
),
|
||||
],
|
||||
Page::Engine => &[(
|
||||
"Audio engine configuration. Select output and input devices, adjust buffer size and polyphony, and manage sample directories. The right side shows a live scope and spectrum analyzer.",
|
||||
&[
|
||||
("Tab", "switch section"),
|
||||
("↑↓", "navigate"),
|
||||
("←→", "adjust"),
|
||||
("R", "restart engine"),
|
||||
("A", "add samples"),
|
||||
("?", "all keys"),
|
||||
],
|
||||
)],
|
||||
Page::Options => &[(
|
||||
"Global settings for display, UI, Link sync, MIDI, etc. All changes save automatically. Tutorial can be reset from here!",
|
||||
&[
|
||||
("↑↓", "navigate"),
|
||||
("←→", "change value"),
|
||||
("?", "all keys"),
|
||||
],
|
||||
)],
|
||||
Page::Help => &[(
|
||||
"Interactive documentation with executable Forth examples. Browse topics on the left, read content on the right. Code blocks can be run directly and evaluated by the sequencer engine.",
|
||||
&[
|
||||
("Tab", "switch panels"),
|
||||
("↑↓", "navigate"),
|
||||
("Enter", "run code block"),
|
||||
("n/p", "next/prev example"),
|
||||
("/", "search"),
|
||||
("?", "all keys"),
|
||||
],
|
||||
)],
|
||||
Page::Dict => &[(
|
||||
"Complete reference of all Forth words by category. Each entry shows the word name, stack effect signature, description, and a usage example. Search filters across all categories.",
|
||||
&[
|
||||
("Tab", "switch panels"),
|
||||
("↑↓", "navigate"),
|
||||
("/", "search"),
|
||||
("?", "all keys"),
|
||||
],
|
||||
)],
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn help_topic_index(self) -> Option<usize> {
|
||||
match self {
|
||||
Page::Main => Some(5), // "Using the Sequencer"
|
||||
|
||||
@@ -602,7 +602,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
inner
|
||||
}
|
||||
Modal::Onboarding { page } => {
|
||||
let pages = app.page.onboarding();
|
||||
let pages = crate::model::onboarding::for_page(app.page);
|
||||
let page_idx = (*page).min(pages.len().saturating_sub(1));
|
||||
let (desc, keys) = pages[page_idx];
|
||||
let page_count = pages.len();
|
||||
|
||||
Reference in New Issue
Block a user