From 3bb1fa6e511ce09840a24fe6b364d7d0c0e6500e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Wed, 4 Feb 2026 19:35:30 +0100 Subject: [PATCH] Some kind of refactoring --- CHANGELOG.md | 4 + refacto.md | 227 --------------- src/app.rs | 507 +++++++-------------------------- src/engine/sequencer.rs | 26 +- src/init.rs | 3 - src/input.rs | 5 + src/main.rs | 3 + src/midi.rs | 2 +- src/model/mod.rs | 10 +- src/model/script.rs | 10 +- src/services/clipboard.rs | 220 ++++++++++++++ src/services/dict_nav.rs | 54 ++++ src/services/euclidean.rs | 56 ++++ src/services/help_nav.rs | 61 ++++ src/services/mod.rs | 5 + src/services/pattern_editor.rs | 37 ++- src/services/stack_preview.rs | 106 +++++++ src/views/render.rs | 124 +------- 18 files changed, 688 insertions(+), 772 deletions(-) delete mode 100644 refacto.md create mode 100644 src/services/clipboard.rs create mode 100644 src/services/dict_nav.rs create mode 100644 src/services/euclidean.rs create mode 100644 src/services/help_nav.rs create mode 100644 src/services/stack_preview.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ccfd59..e1e6c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,13 @@ All notable changes to this project will be documented in this file. ### Changed +- Flattened model re-export indirection; `script.rs` now exports only `ScriptEngine`. - Hue rotation step size increased from 1° to 5° for faster adjustment. - Moved catalog data (DOCS, CATEGORIES) from views to `src/model/`, eliminating state-to-view layer inversion. - Extracted shared initialization into `src/init.rs`, deduplicating ~140 lines between terminal and desktop binaries. +- Split App dispatch into focused service modules (`help_nav`, `dict_nav`, `euclidean`, `clipboard`, extended `pattern_editor`), reducing `app.rs` by ~310 lines. +- Moved stack preview computation from render path to input time, making editor rendering pure. +- Decoupled script runtime state between UI and sequencer threads, eliminating shared mutexes on the RT path. ### Fixed - Desktop binary now loads color theme and connects MIDI devices on startup (was missing). diff --git a/refacto.md b/refacto.md deleted file mode 100644 index 83e8e7d..0000000 --- a/refacto.md +++ /dev/null @@ -1,227 +0,0 @@ -# Refactoring Plan: Isolation and Lower Connascence - -This document outlines practical, incremental refactoring tasks to improve architectural isolation and reduce connascence in this codebase. The goal is to make changes safe and reversible, while reducing coupling between UI, engine, and state. - -Principles: -- Preserve behavior. Refactor in small steps with clear boundaries. -- Reduce connascence by making dependencies explicit and localized. -- Make render paths pure views over state. -- Centralize bootstrapping and cross-cutting wiring. - -## Task 1: Extract Shared Bootstrap (Detailed Checklist) -Problem: -- `src/main.rs` and `src/bin/desktop.rs` duplicate long initialization sequences (settings, audio config, MIDI discovery, samples, sequencer startup). This creates drift and implicit coupling. - -Refactor: -- Create a `bootstrap` module (e.g., `src/bootstrap.rs`) that returns a fully configured `App`, `SequencerHandle`, and any shared runtime state. -- Both binaries call into this module with minimal differences (e.g., terminal vs desktop display setup). - -Checklist: -1. Inventory duplicated blocks - - In `src/main.rs`, locate: settings load, link setup, `App::new`, audio config assignment, MIDI load, sample scan, sequencer spawn, `build_stream`. - - In `src/bin/desktop.rs`, locate the analogous blocks (very similar ordering). -2. Define a shared config struct - - Create a `BootstrapArgs` struct with fields: `samples`, `output`, `input`, `channels`, `buffer`. - - This mirrors CLI args from both binaries. -3. Extract app init - - Create `fn build_app(settings: &Settings, args: &BootstrapArgs) -> App` in a new module (e.g., `src/bootstrap.rs`). - - Move these from both binaries: - - `App::new()` - - app playback queued changes setup - - audio config assignments from settings/args - - UI settings (runtime_highlight, show_scope, show_spectrum, completion, color scheme/hue in terminal path) - - MIDI device selection (output/input device selection based on stored names) -4. Extract audio/sample init - - Create `fn load_samples(paths: &[PathBuf]) -> (Vec, usize)` returning the sample list and count. - - Replace inline `scan_samples_dir` loops in both binaries. -5. Extract sequencer init - - Create `fn build_sequencer(app: &App, settings: &Settings, link: Arc, ...) -> SequencerHandle` that: - - Builds `SequencerConfig` - - Calls `spawn_sequencer` - - Returns the handle + receivers needed for MIDI/audio. -6. Extract stream init - - Create `fn build_audio_stream(config: &AudioStreamConfig, ...) -> Result<(Stream, StreamInfo, AnalysisHandle), Error>` - - Use it in both binaries to set `app.audio` fields. -7. Replace duplicated code - - Update `src/main.rs` and `src/bin/desktop.rs` to call the bootstrap functions. -8. Keep env-specific parts in binary - - Terminal UI init (`crossterm` setup) remains in `src/main.rs`. - - Desktop font/egui setup remains in `src/bin/desktop.rs`. - -Outcome: -- One place to reason about initialization and one place to fix config drift. - -Connascence reduced: -- Reduced connascence of meaning and position across two binary entry points. - -## Task 2: Remove State-to-View Layer Inversion (Detailed Checklist) -Problem: -- `UiState::default` depends on view modules (`dict_view::category_count()`, `help_view::topic_count()`). This inverts layering and creates brittle dependencies. - -Refactor: -- Move those counts into a pure data module, for example `src/state/ui_defaults.rs` or `src/data/help_topics.rs`. -- `UiState::default` should depend only on state/data modules, not views. - -Checklist: -1. Identify view-level sources - - `src/views/help_view.rs` defines topics using `Topic(...)` list. - - `src/views/dict_view.rs` defines category list and `category_count()`. -2. Create a pure data module - - Add a new module (e.g., `src/data/help_dict.rs`) with: - - `pub const HELP_TOPIC_COUNT: usize = ...` - - `pub const DICT_CATEGORY_COUNT: usize = ...` - - If the counts are derived from arrays, move the arrays into this module and re-export. -3. Update views to depend on data module - - `help_view::topic_count()` should return `HELP_TOPIC_COUNT`. - - `dict_view::category_count()` should return `DICT_CATEGORY_COUNT`. -4. Update `UiState::default` - - Replace calls to view modules with `data` module constants in `src/state/ui.rs`. -5. Confirm no other state files import `views` - - Use `rg "views::" src/state` to verify. - -Outcome: -- UI state is decoupled from rendering code. - -Connascence reduced: -- Reduced connascence of type (views no longer required by state). - -## Task 3: Split App Dispatch into Subsystem Handlers (Detailed Checklist) -Problem: -- `App::dispatch` is a very large match that mutates many unrelated parts of state. This makes the App a God object and introduces high connascence across domains. - -Refactor: -- Introduce subsystem handlers: `ProjectController`, `PlaybackController`, `UiController`, `AudioController`, `MidiController`. -- Dispatch routes to a handler based on command domain. - -Checklist: -1. Partition commands by domain - - Playback: `TogglePlaying`, `TempoUp`, `TempoDown`, staging changes. - - Project edits: rename/save/load, pattern/bank changes. - - UI-only: modals, status, help/dict navigation. - - Audio settings: device list navigation, buffer/channels settings. - - MIDI: connect/disconnect, CC, etc. -2. Introduce handler modules - - Create `src/controllers/` (or `src/handlers/`) with `playback.rs`, `project.rs`, `ui.rs`, `audio.rs`, `midi.rs`. -3. Move code in thin slices - - Start with the smallest domain (UI-only commands). - - Move those match arms into `UiController::handle` and call from `App::dispatch`. - - Repeat for each domain. -4. Define minimal interfaces - - Each controller takes only the state it needs (e.g., `&mut UiState`, not `&mut App`). -5. Keep `App::dispatch` as a router - - Single match that forwards to controller or returns early. -6. Reduce `App` field exposure - - Make fields private where possible and provide accessors for controllers. -7. Update tests (if/when added) - - New controllers can be unit-tested without UI/engine wiring. - -Outcome: -- Clear separation of concerns. Smaller, testable units. - -Connascence reduced: -- Reduced connascence of execution and meaning inside one massive switch. - -## Task 4: Make Rendering Pure (No Side Effects / Heavy Work) (Detailed Checklist) -Problem: -- Rendering does real work (`compute_stack_display` runs a Forth evaluation each frame). This couples rendering to runtime behavior and risks UI jank. - -Refactor: -- Compute stack previews in a controller step when editor state changes, store result in `UiState` or a new cache state. -- Renderers become pure read-only views of state. - -Checklist: -1. Locate render-time work - - `src/views/render.rs` → `compute_stack_display` builds a Forth engine and evaluates script. -2. Introduce cache state - - Add a `StackPreview` struct to `src/state/editor.rs` or `src/state/ui.rs`: - - `last_cursor_line`, `lines_hash`, `result`. -3. Define update points - - Update cache when editor content changes or cursor moves. - - Likely locations: - - `input.rs` when editor receives input. - - `app.rs` functions that mutate editor state. -4. Move compute logic to controller/service - - Create `src/services/stack_preview.rs` that computes preview from editor state. - - Call it from update points, store in state. -5. Make render pure - - Replace `compute_stack_display` usage with cached state from `UiState`/`EditorContext`. -6. Verify consistency - - Ensure preview updates on paste and modal editor inputs (see `Event::Paste` handling in `src/main.rs`). - -Outcome: -- Rendering becomes deterministic and cheap. - -Connascence reduced: -- Reduced connascence of timing (render cycle no longer coupled to evaluation). - -## Task 5: Concurrency Boundary for Script Runtime (Detailed Checklist) -Problem: -- `variables`, `dict`, `rng` are shared between UI and sequencer threads via `Arc` and `ArcSwap`. This is a hidden concurrency coupling and risks contention in RT threads. - -Refactor: -- Introduce a message-based interface: UI posts commands to a dedicated script runtime in the sequencer thread. -- UI no longer directly mutates shared `dict`/`vars`. - -Checklist: -1. Identify shared state - - `App::new` constructs `variables`, `dict`, `rng`, `script_engine`. - - These are passed into `spawn_sequencer` in `src/main.rs`. -2. Define a runtime command channel - - New enum `ScriptRuntimeCommand` (e.g., `Reset`, `UpdateVar`, `ClearDict`, etc.). - - Add channel to sequencer config and main loop. -3. Move ownership into sequencer thread - - `spawn_sequencer` creates its own `variables/dict/rng` and `ScriptEngine`. - - Sequencer thread consumes `ScriptRuntimeCommand`s and applies changes. -4. Update UI interaction points - - Anywhere UI currently mutates `variables` or `dict` should send a runtime command instead. - - Example: `App::dispatch` branch that does `self.variables.store(...)` / `self.dict.lock().clear()`. -5. Replace direct shared references - - Remove `ArcSwap` and `Mutex` from `App` for script runtime. -6. Verify audio thread safety - - Ensure no locks are taken in RT critical sections. - -Outcome: -- Clear ownership and fewer concurrency hazards. - -Connascence reduced: -- Reduced connascence of timing and execution across threads. - -## Task 6: Isolate Model from Re-Exports (Detailed Checklist) -Problem: -- `src/model/mod.rs` is mostly re-exports. This blurs boundaries and encourages wide imports. - -Refactor: -- Introduce a narrower domain API layer (e.g., `model::ProjectFacade`) that exposes only the subset the app needs. -- Limit broad re-exports. - -Checklist: -1. Identify heavy imports - - `use crate::model::{...}` in `src/app.rs`, `src/input.rs`, `src/views/*`. -2. Create small facade modules - - e.g., `src/model/project_facade.rs` exporting only `Project`, `Pattern`, `Bank`, etc. - - e.g., `src/model/script_facade.rs` for script-related types. -3. Replace `pub use` in `model/mod.rs` - - Narrow to explicit re-exports or remove when not needed. -4. Update call sites - - Replace `crate::model::*` with focused imports from facade modules. -5. Document boundary - - Add brief module docs explaining what is safe to use externally. - -Outcome: -- Clearer ownership and lower coupling to external crate internals. - -Connascence reduced: -- Reduced connascence of meaning across module boundaries. - -## Suggested Order -- Task 1 (bootstrap) and Task 2 (state/view decoupling) first. These are low risk and improve clarity. -- Task 3 (split dispatch) next to make the codebase easier to change. -- Task 4 (pure rendering) and Task 5 (runtime boundary) after the above to avoid broad ripple effects. -- Task 6 (model isolation) last, because it benefits from clearer boundaries established earlier. - -## Success Criteria -- Startup is defined in one module and reused by both binaries. -- `UiState` compiles without importing anything from `views`. -- `App::dispatch` reduced by at least 50% and sub-handlers exist. -- Render path is a pure function of state (no evaluation, no locks). -- Script runtime state is owned by the sequencer thread. diff --git a/src/app.rs b/src/app.rs index 5bc081a..85c4bb7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,14 +15,13 @@ use crate::engine::{ 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::services::{clipboard, dict_nav, euclidean, help_nav, pattern_editor}; use crate::settings::Settings; use crate::state::{ - AudioSettings, CyclicEnum, DictFocus, EditorContext, FlashKind, LiveKeyState, Metrics, Modal, + AudioSettings, CyclicEnum, EditorContext, FlashKind, LiveKeyState, Metrics, Modal, MuteState, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, PlaybackState, ProjectState, StagedChange, StagedPropChange, UiState, }; -use crate::model::{categories, docs}; const STEPS_PER_PAGE: usize = 32; @@ -41,6 +40,7 @@ pub struct App { pub script_engine: ScriptEngine, pub variables: Variables, pub dict: Dictionary, + #[allow(dead_code)] // kept alive for ScriptEngine's Arc clone pub rng: Rng, pub live_keys: Arc, pub clipboard: Option, @@ -161,14 +161,6 @@ impl App { } } - fn annotate_copy_name(name: &Option) -> Option { - 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(); } @@ -328,6 +320,9 @@ impl App { self.editor_ctx .editor .set_completion_enabled(self.ui.show_completion); + if self.editor_ctx.show_stack { + crate::services::stack_preview::update_cache(&self.editor_ctx); + } } } @@ -638,32 +633,8 @@ impl App { } 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); + 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 @@ -674,34 +645,8 @@ impl App { } 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); - } + 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(); } @@ -714,8 +659,8 @@ impl App { } 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); + 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(); } @@ -723,8 +668,8 @@ impl App { } 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() { + 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 { @@ -734,16 +679,13 @@ impl App { } 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.copied_pattern = Some(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_pattern { - let mut pat = src.clone(); - pat.name = Self::annotate_copy_name(&src.name); - self.project_state.project.banks[bank].patterns[pattern] = pat; + if let Some(src) = self.copied_pattern.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(); @@ -753,17 +695,14 @@ impl App { } pub fn copy_bank(&mut self, bank: usize) { - let b = self.project_state.project.banks[bank].clone(); - self.copied_bank = Some(b); + self.copied_bank = Some(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_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() { + if let Some(src) = self.copied_bank.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 { @@ -776,36 +715,11 @@ impl App { 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() { + 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; } - - 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(); @@ -819,36 +733,18 @@ impl App { 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 { + let (copied, scripts) = clipboard::copy_steps( + &self.project_state.project, bank, pattern, - steps, - }); - + &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); } @@ -858,54 +754,26 @@ impl App { 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(); - } - } - } - + 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(); - - // 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; + 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_step; + self.editor_ctx.step = saved; } - self.editor_ctx.clear_selection(); self.ui.flash( - &format!("Pasted {} steps", copied.steps.len()), + &format!("Pasted {} steps", result.count), 150, FlashKind::Success, ); @@ -916,111 +784,52 @@ impl App { 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; + 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()); } - 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; + 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, + ); } } - - 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)> = 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; - } - + 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 i in 0..pasted { - let target = paste_at + i; + 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 {count} steps"), + &format!("Duplicated {} steps", result.count), 150, FlashKind::Success, ); @@ -1232,100 +1041,28 @@ impl App { 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 = docs::topic_count(); - self.ui.help_topic = (self.ui.help_topic + n) % count; - } - AppCommand::HelpPrevTopic(n) => { - let count = docs::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)) = docs::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)) = docs::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; - } + 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 => { - self.ui.dict_focus = match self.ui.dict_focus { - DictFocus::Categories => DictFocus::Words, - DictFocus::Words => DictFocus::Categories, - }; - } - AppCommand::DictNextCategory => { - let count = categories::category_count(); - self.ui.dict_category = (self.ui.dict_category + 1) % count; - } - AppCommand::DictPrevCategory => { - let count = categories::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; - } + 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 => { @@ -1381,6 +1118,9 @@ impl App { } 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; @@ -1516,51 +1256,26 @@ impl App { 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; - } - + 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 (i, &is_hit) in rhythm.iter().enumerate() { - if !is_hit || i == 0 { - continue; - } - let target = (source_step + i) % pat_len; + 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 {} linked steps (E({pulses},{steps}))", created_count), + &format!("Created {created_count} linked steps (E({pulses},{steps}))"), 200, FlashKind::Success, ); @@ -1624,21 +1339,3 @@ impl App { } } } - -fn euclidean_rhythm(pulses: usize, steps: usize, rotation: usize) -> Vec { - 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 -} diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index e2abea5..454da5b 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -1,5 +1,8 @@ use arc_swap::ArcSwap; use crossbeam_channel::{bounded, unbounded, Receiver, Sender}; +use parking_lot::Mutex; +use rand::rngs::StdRng; +use rand::SeedableRng; use std::collections::HashMap; #[cfg(feature = "desktop")] use std::sync::atomic::AtomicU32; @@ -120,6 +123,7 @@ pub enum SeqCommand { soloed: std::collections::HashSet<(usize, usize)>, }, StopAll, + ResetScriptState, Shutdown, } @@ -274,13 +278,9 @@ pub struct SequencerConfig { pub mouse_down: Arc, } -#[allow(clippy::too_many_arguments)] pub fn spawn_sequencer( link: Arc, playing: Arc, - variables: Variables, - dict: Dictionary, - rng: Rng, quantum: f64, live_keys: Arc, nudge_us: Arc, @@ -329,9 +329,6 @@ pub fn spawn_sequencer( sequencer_audio_tx, link, playing, - variables, - dict, - rng, quantum, shared_state_clone, live_keys, @@ -667,6 +664,15 @@ impl SequencerState { self.runs_counter.counts.clear(); self.audio_state.flush_midi_notes = true; } + SeqCommand::ResetScriptState => { + let variables: Variables = Arc::new(ArcSwap::from_pointee(HashMap::new())); + let dict: Dictionary = Arc::new(Mutex::new(HashMap::new())); + let rng: Rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0))); + self.script_engine = + ScriptEngine::new(Arc::clone(&variables), dict, rng); + self.variables = variables; + self.speed_overrides.clear(); + } SeqCommand::Shutdown => {} } } @@ -1063,9 +1069,6 @@ fn sequencer_loop( audio_tx: Arc>>, link: Arc, playing: Arc, - variables: Variables, - dict: Dictionary, - rng: Rng, quantum: f64, shared_state: Arc>, live_keys: Arc, @@ -1093,6 +1096,9 @@ fn sequencer_loop( eprintln!("[cagire] Then log out and back in."); } + let variables: Variables = Arc::new(ArcSwap::from_pointee(HashMap::new())); + let dict: Dictionary = Arc::new(Mutex::new(HashMap::new())); + let rng: Rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0))); let mut seq_state = SequencerState::new(variables, dict, rng, cc_access); loop { diff --git a/src/init.rs b/src/init.rs index e0735a8..c815dc8 100644 --- a/src/init.rs +++ b/src/init.rs @@ -149,9 +149,6 @@ pub fn init(args: InitArgs) -> Init { let (sequencer, initial_audio_rx, midi_rx) = spawn_sequencer( Arc::clone(&link), Arc::clone(&playing), - Arc::clone(&app.variables), - Arc::clone(&app.dict), - Arc::clone(&app.rng), settings.link.quantum, Arc::clone(&app.live_keys), Arc::clone(&nudge_us), diff --git a/src/input.rs b/src/input.rs index 9cd29a8..93e3cdd 100644 --- a/src/input.rs +++ b/src/input.rs @@ -259,6 +259,7 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { FileBrowserMode::Save => ctx.dispatch(AppCommand::Save(path)), FileBrowserMode::Load => { let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll); + let _ = ctx.seq_cmd_tx.send(SeqCommand::ResetScriptState); ctx.dispatch(AppCommand::Load(path)); load_project_samples(ctx); } @@ -556,6 +557,10 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { editor.input(Event::Key(key)); } } + + if ctx.app.editor_ctx.show_stack { + crate::services::stack_preview::update_cache(&ctx.app.editor_ctx); + } } Modal::Preview => match key.code { KeyCode::Esc | KeyCode::Char('p') => ctx.dispatch(AppCommand::CloseModal), diff --git a/src/main.rs b/src/main.rs index 6cd7b47..472deea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -242,6 +242,9 @@ fn main() -> io::Result<()> { Event::Paste(text) => { if matches!(app.ui.modal, state::Modal::Editor) { app.editor_ctx.editor.insert_str(&text); + if app.editor_ctx.show_stack { + services::stack_preview::update_cache(&app.editor_ctx); + } } } _ => {} diff --git a/src/midi.rs b/src/midi.rs index a00ac4a..5d396eb 100644 --- a/src/midi.rs +++ b/src/midi.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use midir::{MidiInput, MidiOutput}; -use cagire_forth::CcAccess; +use crate::model::CcAccess; pub const MAX_MIDI_OUTPUTS: usize = 4; pub const MAX_MIDI_INPUTS: usize = 4; diff --git a/src/model/mod.rs b/src/model/mod.rs index 483998e..042ab3d 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -2,12 +2,12 @@ pub mod categories; pub mod docs; mod script; -pub use cagire_forth::{lookup_word, Word, WordCompile, WORDS}; +pub use cagire_forth::{ + lookup_word, CcAccess, Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, + Variables, Word, WordCompile, WORDS, +}; pub use cagire_project::{ load, save, Bank, LaunchQuantization, Pattern, PatternSpeed, Project, SyncMode, MAX_BANKS, MAX_PATTERNS, }; -pub use script::{ - CcAccess, Dictionary, ExecutionTrace, Rng, ScriptEngine, SourceSpan, StepContext, Value, - Variables, -}; +pub use script::ScriptEngine; diff --git a/src/model/script.rs b/src/model/script.rs index 0692e24..477dd92 100644 --- a/src/model/script.rs +++ b/src/model/script.rs @@ -1,8 +1,4 @@ -use cagire_forth::Forth; - -pub use cagire_forth::{ - CcAccess, Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables, -}; +use cagire_forth::{Dictionary, ExecutionTrace, Forth, Rng, StepContext, Value, Variables}; pub struct ScriptEngine { forth: Forth, @@ -27,4 +23,8 @@ impl ScriptEngine { ) -> Result, String> { self.forth.evaluate_with_trace(script, ctx, trace) } + + pub fn stack(&self) -> Vec { + self.forth.stack() + } } diff --git a/src/services/clipboard.rs b/src/services/clipboard.rs new file mode 100644 index 0000000..6847014 --- /dev/null +++ b/src/services/clipboard.rs @@ -0,0 +1,220 @@ +use crate::model::{Bank, Pattern, Project}; +use crate::state::{CopiedStepData, CopiedSteps}; + +fn annotate_copy_name(name: &Option) -> Option { + 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 copy_pattern(project: &Project, bank: usize, pattern: usize) -> Pattern { + project.banks[bank].patterns[pattern].clone() +} + +pub fn paste_pattern( + project: &mut Project, + bank: usize, + pattern: usize, + source: &Pattern, +) { + let mut pat = source.clone(); + pat.name = annotate_copy_name(&source.name); + project.banks[bank].patterns[pattern] = pat; +} + +pub fn copy_bank(project: &Project, bank: usize) -> Bank { + project.banks[bank].clone() +} + +pub fn paste_bank(project: &mut Project, bank: usize, source: &Bank) -> usize { + let mut b = source.clone(); + b.name = annotate_copy_name(&source.name); + project.banks[bank] = b; + project.banks[bank].patterns.len() +} + +pub fn copy_steps( + project: &Project, + bank: usize, + pattern: usize, + indices: &[usize], +) -> (CopiedSteps, Vec) { + let pat = project.pattern_at(bank, pattern); + 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(CopiedStepData { + script: resolved, + active: step.active, + source: step.source, + original_index: idx, + name: step.name.clone(), + }); + } + } + + let copied = CopiedSteps { + bank, + pattern, + steps, + }; + (copied, scripts) +} + +pub struct PasteResult { + pub count: usize, + pub compile_targets: Vec, +} + +pub fn paste_steps( + project: &mut Project, + bank: usize, + pattern: usize, + cursor: usize, + copied: &CopiedSteps, +) -> PasteResult { + let pat_len = project.pattern_at(bank, pattern).length; + let same_pattern = copied.bank == bank && copied.pattern == pattern; + let mut compile_targets = Vec::new(); + + for (i, data) in copied.steps.iter().enumerate() { + let target = cursor + i; + if target >= pat_len { + break; + } + if let Some(step) = 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(); + } + } + compile_targets.push(target); + } + + PasteResult { + count: copied.steps.len(), + compile_targets, + } +} + +pub fn link_paste_steps( + project: &mut Project, + bank: usize, + pattern: usize, + cursor: usize, + copied: &CopiedSteps, +) -> Option { + if copied.bank != bank || copied.pattern != pattern { + return None; + } + + let pat_len = project.pattern_at(bank, pattern).length; + + 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() { + data.source + } else { + Some(data.original_index) + }; + if source_idx == Some(target) { + continue; + } + if let Some(step) = project.pattern_at_mut(bank, pattern).step_mut(target) { + step.source = source_idx; + step.script.clear(); + step.command = None; + } + } + + Some(copied.steps.len()) +} + +pub fn harden_steps( + project: &mut Project, + bank: usize, + pattern: usize, + indices: &[usize], +) -> usize { + let pat = 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(); + + let count = resolutions.len(); + for (idx, script) in resolutions { + if let Some(s) = project.pattern_at_mut(bank, pattern).step_mut(idx) { + s.source = None; + s.script = script; + } + } + + count +} + +pub fn duplicate_steps( + project: &mut Project, + bank: usize, + pattern: usize, + indices: &[usize], +) -> PasteResult { + let pat = project.pattern_at(bank, pattern); + let pat_len = pat.length; + let paste_at = *indices.last().unwrap() + 1; + + let dupe_data: Vec<(bool, String, Option)> = 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 compile_targets = Vec::new(); + 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) = 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; + } + } + compile_targets.push(target); + } + + PasteResult { + count: indices.len(), + compile_targets, + } +} diff --git a/src/services/dict_nav.rs b/src/services/dict_nav.rs new file mode 100644 index 0000000..21e49e9 --- /dev/null +++ b/src/services/dict_nav.rs @@ -0,0 +1,54 @@ +use crate::model::categories; +use crate::state::{DictFocus, UiState}; + +pub fn toggle_focus(ui: &mut UiState) { + ui.dict_focus = match ui.dict_focus { + DictFocus::Categories => DictFocus::Words, + DictFocus::Words => DictFocus::Categories, + }; +} + +pub fn next_category(ui: &mut UiState) { + let count = categories::category_count(); + ui.dict_category = (ui.dict_category + 1) % count; +} + +pub fn prev_category(ui: &mut UiState) { + let count = categories::category_count(); + ui.dict_category = (ui.dict_category + count - 1) % count; +} + +pub fn scroll_down(ui: &mut UiState, n: usize) { + let s = ui.dict_scroll_mut(); + *s = s.saturating_add(n); +} + +pub fn scroll_up(ui: &mut UiState, n: usize) { + let s = ui.dict_scroll_mut(); + *s = s.saturating_sub(n); +} + +pub fn activate_search(ui: &mut UiState) { + ui.dict_search_active = true; + ui.dict_focus = DictFocus::Words; +} + +pub fn clear_search(ui: &mut UiState) { + ui.dict_search_query.clear(); + ui.dict_search_active = false; + *ui.dict_scroll_mut() = 0; +} + +pub fn search_input(ui: &mut UiState, c: char) { + ui.dict_search_query.push(c); + *ui.dict_scroll_mut() = 0; +} + +pub fn search_backspace(ui: &mut UiState) { + ui.dict_search_query.pop(); + *ui.dict_scroll_mut() = 0; +} + +pub fn search_confirm(ui: &mut UiState) { + ui.dict_search_active = false; +} diff --git a/src/services/euclidean.rs b/src/services/euclidean.rs new file mode 100644 index 0000000..0806680 --- /dev/null +++ b/src/services/euclidean.rs @@ -0,0 +1,56 @@ +use crate::model::Project; + +pub fn euclidean_rhythm(pulses: usize, steps: usize, rotation: usize) -> Vec { + 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 +} + +/// Applies euclidean distribution as linked steps from a source step. +/// Returns the indices of steps that were created (for compilation). +pub fn apply_distribution( + project: &mut Project, + bank: usize, + pattern: usize, + source_step: usize, + pulses: usize, + steps: usize, + rotation: usize, +) -> Vec { + let pat_len = project.pattern_at(bank, pattern).length; + let rhythm = euclidean_rhythm(pulses, steps, rotation); + + let mut targets = Vec::new(); + for (i, &is_hit) in rhythm.iter().enumerate() { + if !is_hit || i == 0 { + continue; + } + + let target = (source_step + i) % pat_len; + if target == source_step { + continue; + } + + if let Some(step) = project.pattern_at_mut(bank, pattern).step_mut(target) { + step.source = Some(source_step); + step.script.clear(); + step.command = None; + step.active = true; + } + targets.push(target); + } + + targets +} diff --git a/src/services/help_nav.rs b/src/services/help_nav.rs new file mode 100644 index 0000000..918b2a9 --- /dev/null +++ b/src/services/help_nav.rs @@ -0,0 +1,61 @@ +use crate::model::docs; +use crate::state::{HelpFocus, UiState}; + +pub fn toggle_focus(ui: &mut UiState) { + ui.help_focus = match ui.help_focus { + HelpFocus::Topics => HelpFocus::Content, + HelpFocus::Content => HelpFocus::Topics, + }; +} + +pub fn next_topic(ui: &mut UiState, n: usize) { + let count = docs::topic_count(); + ui.help_topic = (ui.help_topic + n) % count; +} + +pub fn prev_topic(ui: &mut UiState, n: usize) { + let count = docs::topic_count(); + ui.help_topic = (ui.help_topic + count - (n % count)) % count; +} + +pub fn scroll_down(ui: &mut UiState, n: usize) { + let s = ui.help_scroll_mut(); + *s = s.saturating_add(n); +} + +pub fn scroll_up(ui: &mut UiState, n: usize) { + let s = ui.help_scroll_mut(); + *s = s.saturating_sub(n); +} + +pub fn activate_search(ui: &mut UiState) { + ui.help_search_active = true; +} + +pub fn clear_search(ui: &mut UiState) { + ui.help_search_query.clear(); + ui.help_search_active = false; +} + +pub fn search_input(ui: &mut UiState, c: char) { + ui.help_search_query.push(c); + if let Some((topic, line)) = docs::find_match(&ui.help_search_query) { + ui.help_topic = topic; + ui.help_scrolls[topic] = line; + } +} + +pub fn search_backspace(ui: &mut UiState) { + ui.help_search_query.pop(); + if ui.help_search_query.is_empty() { + return; + } + if let Some((topic, line)) = docs::find_match(&ui.help_search_query) { + ui.help_topic = topic; + ui.help_scrolls[topic] = line; + } +} + +pub fn search_confirm(ui: &mut UiState) { + ui.help_search_active = false; +} diff --git a/src/services/mod.rs b/src/services/mod.rs index f4feb56..9307b09 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1 +1,6 @@ +pub mod clipboard; +pub mod dict_nav; +pub mod euclidean; +pub mod help_nav; pub mod pattern_editor; +pub mod stack_preview; diff --git a/src/services/pattern_editor.rs b/src/services/pattern_editor.rs index aac9bde..fef7014 100644 --- a/src/services/pattern_editor.rs +++ b/src/services/pattern_editor.rs @@ -1,4 +1,4 @@ -use crate::model::{PatternSpeed, Project}; +use crate::model::{Bank, Pattern, PatternSpeed, Project}; #[derive(Debug, Clone, Copy)] pub struct PatternEdit { @@ -90,3 +90,38 @@ pub fn get_step_script( .step(step) .map(|s| s.script.clone()) } + +pub fn delete_step(project: &mut Project, bank: usize, pattern: usize, step: usize) -> PatternEdit { + let pat = 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; + } + } + + set_step_script(project, bank, pattern, step, String::new()); + if let Some(s) = project.pattern_at_mut(bank, pattern).step_mut(step) { + s.command = None; + s.source = None; + } + PatternEdit::new(bank, pattern) +} + +pub fn delete_steps(project: &mut Project, bank: usize, pattern: usize, steps: &[usize]) -> PatternEdit { + for &step in steps { + delete_step(project, bank, pattern, step); + } + PatternEdit::new(bank, pattern) +} + +pub fn reset_pattern(project: &mut Project, bank: usize, pattern: usize) -> PatternEdit { + project.banks[bank].patterns[pattern] = Pattern::default(); + PatternEdit::new(bank, pattern) +} + +pub fn reset_bank(project: &mut Project, bank: usize) -> usize { + project.banks[bank] = Bank::default(); + project.banks[bank].patterns.len() +} diff --git a/src/services/stack_preview.rs b/src/services/stack_preview.rs new file mode 100644 index 0000000..35b2e30 --- /dev/null +++ b/src/services/stack_preview.rs @@ -0,0 +1,106 @@ +use arc_swap::ArcSwap; +use parking_lot::Mutex; +use std::collections::hash_map::DefaultHasher; +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use rand::rngs::StdRng; +use rand::SeedableRng; + +use crate::model::{ScriptEngine, StepContext, Value}; +use crate::state::{EditorContext, StackCache}; + +pub fn update_cache(editor_ctx: &EditorContext) { + let lines = editor_ctx.editor.lines(); + let cursor_line = editor_ctx.editor.cursor().0; + + let mut hasher = DefaultHasher::new(); + for (i, line) in lines.iter().enumerate() { + if i > cursor_line { + break; + } + line.hash(&mut hasher); + } + let lines_hash = hasher.finish(); + + if let Some(ref c) = *editor_ctx.stack_cache.borrow() { + if c.cursor_line == cursor_line && c.lines_hash == lines_hash { + return; + } + } + + let partial: Vec<&str> = lines + .iter() + .take(cursor_line + 1) + .map(|s| s.as_str()) + .collect(); + let script = partial.join("\n"); + + let result = if script.trim().is_empty() { + "Stack: []".to_string() + } else { + let vars = 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(42))); + let engine = ScriptEngine::new(vars, dict, rng); + + let ctx = StepContext { + step: 0, + beat: 0.0, + bank: 0, + pattern: 0, + tempo: 120.0, + phase: 0.0, + slot: 0, + runs: 0, + iter: 0, + speed: 1.0, + 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, + }; + + match engine.evaluate(&script, &ctx) { + Ok(_) => { + let stack = engine.stack(); + let formatted: Vec = stack.iter().map(format_value).collect(); + format!("Stack: [{}]", formatted.join(" ")) + } + Err(e) => format!("Error: {e}"), + } + }; + + *editor_ctx.stack_cache.borrow_mut() = Some(StackCache { + cursor_line, + lines_hash, + result, + }); +} + +fn format_value(v: &Value) -> String { + match v { + Value::Int(n, _) => n.to_string(), + Value::Float(f, _) => { + if f.fract() == 0.0 && f.abs() < 1_000_000.0 { + format!("{f:.1}") + } else { + format!("{f:.4}") + } + } + Value::Str(s, _) => format!("\"{s}\""), + Value::Quotation(..) => "[...]".to_string(), + Value::CycleList(items) => { + let inner: Vec = items.iter().map(format_value).collect(); + format!("({})", inner.join(" ")) + } + } +} diff --git a/src/views/render.rs b/src/views/render.rs index d993208..fae2c9d 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -1,13 +1,5 @@ -use arc_swap::ArcSwap; -use parking_lot::Mutex; -use std::collections::hash_map::DefaultHasher; -use std::collections::HashMap; -use std::hash::{Hash, Hasher}; -use std::sync::Arc; use std::time::{Duration, Instant}; -use rand::rngs::StdRng; -use rand::SeedableRng; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; @@ -16,121 +8,21 @@ use ratatui::Frame; use crate::app::App; use crate::engine::{LinkState, SequencerSnapshot}; -use crate::model::{SourceSpan, StepContext, Value}; +use crate::model::SourceSpan; use crate::page::Page; use crate::state::{ - EuclideanField, FlashKind, Modal, PanelFocus, PatternField, SidePanel, StackCache, + EuclideanField, FlashKind, Modal, PanelFocus, PatternField, SidePanel, }; use crate::theme; use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime}; use crate::widgets::{ ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal, }; -use cagire_forth::Forth; use super::{ dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view, }; -fn compute_stack_display( - lines: &[String], - editor: &cagire_ratatui::Editor, - cache: &std::cell::RefCell>, -) -> String { - let cursor_line = editor.cursor().0; - - let mut hasher = DefaultHasher::new(); - for (i, line) in lines.iter().enumerate() { - if i > cursor_line { - break; - } - line.hash(&mut hasher); - } - let lines_hash = hasher.finish(); - - if let Some(ref c) = *cache.borrow() { - if c.cursor_line == cursor_line && c.lines_hash == lines_hash { - return c.result.clone(); - } - } - - let partial: Vec<&str> = lines - .iter() - .take(cursor_line + 1) - .map(|s| s.as_str()) - .collect(); - let script = partial.join("\n"); - - let result = if script.trim().is_empty() { - "Stack: []".to_string() - } else { - let vars = 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(42))); - let forth = Forth::new(vars, dict, rng); - - let ctx = StepContext { - step: 0, - beat: 0.0, - bank: 0, - pattern: 0, - tempo: 120.0, - phase: 0.0, - slot: 0, - runs: 0, - iter: 0, - speed: 1.0, - 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, - }; - - match forth.evaluate(&script, &ctx) { - Ok(_) => { - let stack = forth.stack(); - let formatted: Vec = stack.iter().map(format_value).collect(); - format!("Stack: [{}]", formatted.join(" ")) - } - Err(e) => format!("Error: {e}"), - } - }; - - *cache.borrow_mut() = Some(StackCache { - cursor_line, - lines_hash, - result: result.clone(), - }); - - result -} - -fn format_value(v: &Value) -> String { - match v { - Value::Int(n, _) => n.to_string(), - Value::Float(f, _) => { - if f.fract() == 0.0 && f.abs() < 1_000_000.0 { - format!("{f:.1}") - } else { - format!("{f:.4}") - } - } - Value::Str(s, _) => format!("\"{s}\""), - Value::Quotation(..) => "[...]".to_string(), - Value::CycleList(items) => { - let inner: Vec = items.iter().map(format_value).collect(); - format!("({})", inner.join(" ")) - } - } -} - fn adjust_spans_for_line( spans: &[SourceSpan], line_start: usize, @@ -850,11 +742,13 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term ]); frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area); } else if app.editor_ctx.show_stack { - let stack_text = compute_stack_display( - text_lines, - &app.editor_ctx.editor, - &app.editor_ctx.stack_cache, - ); + let stack_text = app + .editor_ctx + .stack_cache + .borrow() + .as_ref() + .map(|c| c.result.clone()) + .unwrap_or_else(|| "Stack: []".to_string()); let hint = Line::from(vec![ Span::styled("Esc", key), Span::styled(" save ", dim),