2 Commits

Author SHA1 Message Date
1ce5b8597a Feat: cleanup
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s
2026-02-22 13:28:03 +01:00
789dbb186b Feat: CHANGELOG updates 2026-02-22 12:55:58 +01:00
27 changed files with 218 additions and 181 deletions

View File

@@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file.
- Early CLAP plugin support via nih-plug, baseview, and egui. Feature-gated builds separate CLI from plugin targets. - Early CLAP plugin support via nih-plug, baseview, and egui. Feature-gated builds separate CLI from plugin targets.
### Forth Language ### Forth Language
- Removed `chain` word (replaced by pattern-level Follow Up setting).
- `case/of/endof/endcase` control flow for pattern-matching dispatch. - `case/of/endof/endcase` control flow for pattern-matching dispatch.
- `bjork` / `pbjork` — euclidean rhythm gates using quotations: execute a block only on Bjorklund-distributed hits. - `bjork` / `pbjork` — euclidean rhythm gates using quotations: execute a block only on Bjorklund-distributed hits.
- `arp` — arpeggio list type that spreads notes across time positions instead of stacking them simultaneously. - `arp` — arpeggio list type that spreads notes across time positions instead of stacking them simultaneously.
@@ -21,11 +22,15 @@ All notable changes to this project will be documented in this file.
- Reverb parameter words added. - Reverb parameter words added.
### Engine ### Engine
- Follow-up actions: patterns now have a configurable follow-up behavior (Loop, Stop, or Chain to another pattern). Replaces the Forth `chain` word with a declarative setting in the Pattern Properties modal (`e` key). Chain targets specify bank and pattern via UI fields.
- Delta-time MIDI scheduling for tighter, sample-accurate timing. - Delta-time MIDI scheduling for tighter, sample-accurate timing.
- Tempo and current beat exposed in sequencer snapshot. - Tempo and current beat exposed in sequencer snapshot.
- Spectrum analyzer rescaling. - Spectrum analyzer rescaling.
### UI / UX ### UI / UX
- Patterns view redesign: new layout with banks column (showing pattern counts), expandable detail rows for the focused pattern (quantization, sync mode, progress bar), and a bottom preview strip with mini step grid and pattern properties.
- Smooth playback progress: playing patterns display a real-time progress bar interpolated between steps.
- Dynamic step grid sizing: `steps_per_page` adapts to terminal height instead of using a fixed constant.
- Mouse support: click navigation on the pattern grid, panels, and modals. - Mouse support: click navigation on the pattern grid, panels, and modals.
- F1F6 page navigation across the 3×2 page grid. - F1F6 page navigation across the 3×2 page grid.
- Collapsible help sections with code block copy. - Collapsible help sections with code block copy.

View File

@@ -1,3 +1,5 @@
//! Single-pass compiler from Forth source text to Op sequences.
use std::borrow::Cow; use std::borrow::Cow;
use std::sync::Arc; use std::sync::Arc;

View File

@@ -1,3 +1,5 @@
//! Forth virtual machine for the Cagire music sequencer.
mod compiler; mod compiler;
mod ops; mod ops;
mod theory; mod theory;

View File

@@ -1,3 +1,5 @@
//! Compiled operation variants for the Forth VM instruction set.
use std::sync::Arc; use std::sync::Arc;
use super::types::SourceSpan; use super::types::SourceSpan;

View File

@@ -1,3 +1,5 @@
//! Core types for the Forth VM: values, execution context, and shared state.
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use parking_lot::Mutex; use parking_lot::Mutex;
use rand::rngs::StdRng; use rand::rngs::StdRng;

View File

@@ -1,3 +1,5 @@
//! Stack-based Forth interpreter with audio command generation.
use parking_lot::Mutex; use parking_lot::Mutex;
use rand::rngs::StdRng; use rand::rngs::StdRng;
use rand::{Rng as RngTrait, SeedableRng}; use rand::{Rng as RngTrait, SeedableRng};
@@ -29,12 +31,10 @@ impl Forth {
} }
} }
#[allow(dead_code)]
pub fn stack(&self) -> Vec<Value> { pub fn stack(&self) -> Vec<Value> {
self.stack.lock().clone() self.stack.lock().clone()
} }
#[allow(dead_code)]
pub fn clear_stack(&self) { pub fn clear_stack(&self) {
self.stack.lock().clear(); self.stack.lock().clear();
} }

View File

@@ -1,3 +1,5 @@
//! Project data model: banks, patterns, and steps for the Cagire sequencer.
mod file; mod file;
mod project; mod project;

View File

@@ -1,3 +1,5 @@
//! Project, Bank, Pattern, and Step structs with serialization.
use std::path::PathBuf; use std::path::PathBuf;
use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde::{Deserialize, Deserializer, Serialize, Serializer};

View File

@@ -1,3 +1,5 @@
//! Reusable TUI widgets for the Cagire sequencer interface.
mod category_list; mod category_list;
mod confirm; mod confirm;
mod editor; mod editor;

View File

@@ -15,15 +15,17 @@ impl App {
} }
pub fn paste_pattern(&mut self, bank: usize, pattern: usize) { pub fn paste_pattern(&mut self, bank: usize, pattern: usize) {
if let Some(src) = self.copied_patterns.as_ref().and_then(|v| v.first()) { if let Some(patterns) = self.copied_patterns.take() {
let src = src.clone(); if let Some(src) = patterns.first() {
clipboard::paste_pattern(&mut self.project_state.project, bank, pattern, &src); clipboard::paste_pattern(&mut self.project_state.project, bank, pattern, src);
self.project_state.mark_dirty(bank, pattern); self.project_state.mark_dirty(bank, pattern);
if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern { if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern {
self.load_step_to_editor(); self.load_step_to_editor();
} }
self.ui.flash("Pattern pasted", 150, FlashKind::Success); self.ui.flash("Pattern pasted", 150, FlashKind::Success);
} }
self.copied_patterns = Some(patterns);
}
} }
pub fn copy_patterns(&mut self, bank: usize, patterns: &[usize]) { pub fn copy_patterns(&mut self, bank: usize, patterns: &[usize]) {
@@ -41,13 +43,14 @@ impl App {
} }
pub fn paste_patterns(&mut self, bank: usize, start: usize) { pub fn paste_patterns(&mut self, bank: usize, start: usize) {
if let Some(sources) = self.copied_patterns.clone() { if let Some(sources) = self.copied_patterns.take() {
let count = clipboard::paste_patterns( let count = clipboard::paste_patterns(
&mut self.project_state.project, &mut self.project_state.project,
bank, bank,
start, start,
&sources, &sources,
); );
self.copied_patterns = Some(sources);
for i in 0..count { for i in 0..count {
self.project_state.mark_dirty(bank, start + i); self.project_state.mark_dirty(bank, start + i);
} }
@@ -111,10 +114,10 @@ impl App {
} }
pub fn paste_bank(&mut self, bank: usize) { pub fn paste_bank(&mut self, bank: usize) {
if let Some(src) = self.copied_banks.as_ref().and_then(|v| v.first()) { if let Some(banks) = self.copied_banks.take() {
let src = src.clone(); if let Some(src) = banks.first() {
let pat_count = let pat_count =
clipboard::paste_bank(&mut self.project_state.project, bank, &src); clipboard::paste_bank(&mut self.project_state.project, bank, src);
for pattern in 0..pat_count { for pattern in 0..pat_count {
self.project_state.mark_dirty(bank, pattern); self.project_state.mark_dirty(bank, pattern);
} }
@@ -123,6 +126,8 @@ impl App {
} }
self.ui.flash("Bank pasted", 150, FlashKind::Success); self.ui.flash("Bank pasted", 150, FlashKind::Success);
} }
self.copied_banks = Some(banks);
}
} }
pub fn copy_banks(&mut self, banks: &[usize]) { pub fn copy_banks(&mut self, banks: &[usize]) {
@@ -139,12 +144,13 @@ impl App {
} }
pub fn paste_banks(&mut self, start: usize) { pub fn paste_banks(&mut self, start: usize) {
if let Some(sources) = self.copied_banks.clone() { if let Some(sources) = self.copied_banks.take() {
let count = clipboard::paste_banks( let count = clipboard::paste_banks(
&mut self.project_state.project, &mut self.project_state.project,
start, start,
&sources, &sources,
); );
self.copied_banks = Some(sources);
for i in 0..count { for i in 0..count {
let bank = start + i; let bank = start + i;
for pattern in 0..model::MAX_PATTERNS { for pattern in 0..model::MAX_PATTERNS {
@@ -184,23 +190,24 @@ impl App {
pub fn copy_steps(&mut self) { pub fn copy_steps(&mut self) {
let (bank, pattern) = self.current_bank_pattern(); let (bank, pattern) = self.current_bank_pattern();
let indices = self.selected_steps(); let indices = self.selected_steps();
let (copied, scripts) = clipboard::copy_steps( let copied = clipboard::copy_steps(
&self.project_state.project, &self.project_state.project,
bank, bank,
pattern, pattern,
&indices, &indices,
); );
let count = copied.steps.len(); let count = copied.steps.len();
self.editor_ctx.copied_steps = Some(copied);
if let Some(clip) = &mut self.clipboard { if let Some(clip) = &mut self.clipboard {
let _ = clip.set_text(scripts.join("\n")); let text: String = copied.steps.iter().map(|s| s.script.as_str()).collect::<Vec<_>>().join("\n");
let _ = clip.set_text(text);
} }
self.editor_ctx.copied_steps = Some(copied);
self.ui self.ui
.flash(&format!("Copied {count} steps"), 150, FlashKind::Info); .flash(&format!("Copied {count} steps"), 150, FlashKind::Info);
} }
pub fn paste_steps(&mut self, link: &crate::engine::LinkState) { pub fn paste_steps(&mut self, link: &crate::engine::LinkState) {
let Some(copied) = self.editor_ctx.copied_steps.clone() else { let Some(copied) = self.editor_ctx.copied_steps.take() else {
self.ui.set_status("Nothing copied".to_string()); self.ui.set_status("Nothing copied".to_string());
return; return;
}; };
@@ -213,6 +220,7 @@ impl App {
cursor, cursor,
&copied, &copied,
); );
self.editor_ctx.copied_steps = Some(copied);
self.project_state.mark_dirty(bank, pattern); self.project_state.mark_dirty(bank, pattern);
self.load_step_to_editor(); self.load_step_to_editor();
for &target in &result.compile_targets { for &target in &result.compile_targets {
@@ -230,19 +238,21 @@ impl App {
} }
pub fn link_paste_steps(&mut self) { pub fn link_paste_steps(&mut self) {
let Some(copied) = self.editor_ctx.copied_steps.clone() else { let Some(copied) = self.editor_ctx.copied_steps.take() else {
self.ui.set_status("Nothing copied".to_string()); self.ui.set_status("Nothing copied".to_string());
return; return;
}; };
let (bank, pattern) = self.current_bank_pattern(); let (bank, pattern) = self.current_bank_pattern();
let cursor = self.editor_ctx.step; let cursor = self.editor_ctx.step;
match clipboard::link_paste_steps( let result = clipboard::link_paste_steps(
&mut self.project_state.project, &mut self.project_state.project,
bank, bank,
pattern, pattern,
cursor, cursor,
&copied, &copied,
) { );
self.editor_ctx.copied_steps = Some(copied);
match result {
None => { None => {
self.ui self.ui
.set_status("Can only link within same pattern".to_string()); .set_status("Can only link within same pattern".to_string());

View File

@@ -1,5 +1,8 @@
//! Routes `AppCommand` variants to the appropriate `App` methods.
use crate::commands::AppCommand; use crate::commands::AppCommand;
use crate::engine::{LinkState, SequencerSnapshot}; use crate::engine::{LinkState, SequencerSnapshot};
use crate::model::bp_label;
use crate::services::{dict_nav, euclidean, help_nav, pattern_editor}; use crate::services::{dict_nav, euclidean, help_nav, pattern_editor};
use crate::state::{undo::UndoEntry, FlashKind, Modal, StagedPropChange}; use crate::state::{undo::UndoEntry, FlashKind, Modal, StagedPropChange};
@@ -193,11 +196,8 @@ impl App {
follow_up, follow_up,
}, },
); );
self.ui.set_status(format!( self.ui
"B{:02}:P{:02} props staged", .set_status(format!("{} props staged", bp_label(bank, pattern)));
bank + 1,
pattern + 1
));
} }
// Page navigation // Page navigation

View File

@@ -1,3 +1,5 @@
//! Application state: owns the project, editor context, and all UI/playback state.
mod clipboard; mod clipboard;
mod dispatch; mod dispatch;
mod editing; mod editing;
@@ -54,8 +56,8 @@ pub struct App {
pub script_engine: ScriptEngine, pub script_engine: ScriptEngine,
pub variables: Variables, pub variables: Variables,
pub dict: Dictionary, pub dict: Dictionary,
#[allow(dead_code)] // Held to keep the Arc alive (shared with ScriptEngine).
pub rng: Rng, pub _rng: Rng,
pub live_keys: Arc<LiveKeyState>, pub live_keys: Arc<LiveKeyState>,
pub clipboard: Option<arboard::Clipboard>, pub clipboard: Option<arboard::Clipboard>,
pub copied_patterns: Option<Vec<Pattern>>, pub copied_patterns: Option<Vec<Pattern>>,
@@ -108,7 +110,7 @@ impl App {
metrics: Metrics::default(), metrics: Metrics::default(),
variables, variables,
dict, dict,
rng, _rng: rng,
live_keys, live_keys,
script_engine, script_engine,
clipboard: arboard::Clipboard::new().ok(), clipboard: arboard::Clipboard::new().ok(),

View File

@@ -1,4 +1,5 @@
use crate::engine::{PatternChange, SequencerSnapshot}; use crate::engine::{PatternChange, SequencerSnapshot};
use crate::model::bp_label;
use crate::state::StagedChange; use crate::state::StagedChange;
use super::App; use super::App;
@@ -20,29 +21,23 @@ impl App {
if let Some(idx) = existing { if let Some(idx) = existing {
self.playback.staged_changes.remove(idx); self.playback.staged_changes.remove(idx);
self.ui self.ui
.set_status(format!("B{:02}:P{:02} unstaged", bank + 1, pattern + 1)); .set_status(format!("{} unstaged", bp_label(bank, pattern)));
} else if is_playing { } else if is_playing {
self.playback.staged_changes.push(StagedChange { self.playback.staged_changes.push(StagedChange {
change: PatternChange::Stop { bank, pattern }, change: PatternChange::Stop { bank, pattern },
quantization: pattern_data.quantization, quantization: pattern_data.quantization,
sync_mode: pattern_data.sync_mode, sync_mode: pattern_data.sync_mode,
}); });
self.ui.set_status(format!( self.ui
"B{:02}:P{:02} staged to stop", .set_status(format!("{} staged to stop", bp_label(bank, pattern)));
bank + 1,
pattern + 1
));
} else { } else {
self.playback.staged_changes.push(StagedChange { self.playback.staged_changes.push(StagedChange {
change: PatternChange::Start { bank, pattern }, change: PatternChange::Start { bank, pattern },
quantization: pattern_data.quantization, quantization: pattern_data.quantization,
sync_mode: pattern_data.sync_mode, sync_mode: pattern_data.sync_mode,
}); });
self.ui.set_status(format!( self.ui
"B{:02}:P{:02} staged to play", .set_status(format!("{} staged to play", bp_label(bank, pattern)));
bank + 1,
pattern + 1
));
} }
} }

View File

@@ -64,15 +64,19 @@ impl App {
let cursor = (self.editor_ctx.bank, self.editor_ctx.pattern, self.editor_ctx.step); let cursor = (self.editor_ctx.bank, self.editor_ctx.pattern, self.editor_ctx.step);
let reverse_scope = match entry.scope { let reverse_scope = match entry.scope {
UndoScope::Pattern { bank, pattern, data } => { UndoScope::Pattern { bank, pattern, data } => {
let current = self.project_state.project.pattern_at(bank, pattern).clone(); let current = std::mem::replace(
*self.project_state.project.pattern_at_mut(bank, pattern) = data; self.project_state.project.pattern_at_mut(bank, pattern),
data,
);
self.project_state.mark_dirty(bank, pattern); self.project_state.mark_dirty(bank, pattern);
UndoScope::Pattern { bank, pattern, data: current } UndoScope::Pattern { bank, pattern, data: current }
} }
UndoScope::Bank { bank, data } => { UndoScope::Bank { bank, data } => {
let current = self.project_state.project.banks[bank].clone(); let current = std::mem::replace(
&mut self.project_state.project.banks[bank],
data,
);
let pat_count = current.patterns.len(); let pat_count = current.patterns.len();
self.project_state.project.banks[bank] = data;
for p in 0..pat_count { for p in 0..pat_count {
self.project_state.mark_dirty(bank, p); self.project_state.mark_dirty(bank, p);
} }

View File

@@ -1,3 +1,5 @@
//! All user actions expressed as the `AppCommand` enum, dispatched by `App::dispatch()`.
use std::path::PathBuf; use std::path::PathBuf;
use crate::model::{FollowUp, LaunchQuantization, PatternSpeed, SyncMode}; use crate::model::{FollowUp, LaunchQuantization, PatternSpeed, SyncMode};

View File

@@ -1,3 +1,5 @@
//! Audio output stream (cpal) and FFT spectrum analysis.
use ringbuf::{traits::*, HeapRb}; use ringbuf::{traits::*, HeapRb};
use rustfft::{num_complex::Complex, FftPlanner}; use rustfft::{num_complex::Complex, FftPlanner};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
@@ -173,8 +175,8 @@ impl SpectrumAnalyzer {
pub struct AnalysisHandle { pub struct AnalysisHandle {
running: Arc<AtomicBool>, running: Arc<AtomicBool>,
#[allow(dead_code)] // Held to keep the thread alive until this handle is dropped.
thread: Option<JoinHandle<()>>, _thread: Option<JoinHandle<()>>,
} }
impl Drop for AnalysisHandle { impl Drop for AnalysisHandle {
@@ -202,7 +204,7 @@ pub fn spawn_analysis_thread(
let handle = AnalysisHandle { let handle = AnalysisHandle {
running, running,
thread: Some(thread), _thread: Some(thread),
}; };
(producer, handle) (producer, handle)

View File

@@ -7,20 +7,27 @@ mod timing;
pub use timing::{substeps_in_window, StepTiming, SyncTime}; pub use timing::{substeps_in_window, StepTiming, SyncTime};
// Used by plugin and desktop crates via the lib; not by the terminal binary directly. pub use audio::{preload_sample_heads, AnalysisHandle, ScopeBuffer, SpectrumBuffer};
// Re-exported for the plugin crate (not used by the terminal binary).
#[allow(unused_imports)] #[allow(unused_imports)]
pub use audio::{ pub use audio::spawn_analysis_thread;
preload_sample_heads, spawn_analysis_thread, AnalysisHandle, ScopeBuffer, SpectrumBuffer,
};
#[cfg(feature = "cli")]
pub use audio::{build_stream, AudioStreamConfig};
#[cfg(feature = "cli")] #[cfg(feature = "cli")]
#[allow(unused_imports)] #[allow(unused_imports)]
pub use audio::{build_stream, AudioStreamConfig, AudioStreamInfo}; pub use audio::AudioStreamInfo;
pub use link::LinkState; pub use link::LinkState;
pub use sequencer::{
spawn_sequencer, AudioCommand, MidiCommand, PatternChange, PatternSnapshot, SeqCommand,
SequencerConfig, SequencerHandle, SequencerSnapshot, StepSnapshot,
};
// Re-exported for the plugin crate (not used by the terminal binary).
#[allow(unused_imports)] #[allow(unused_imports)]
pub use sequencer::{ pub use sequencer::{
parse_midi_command, spawn_sequencer, AudioCommand, MidiCommand, PatternChange, PatternSnapshot, parse_midi_command, SequencerState, SharedSequencerState, TickInput, TickOutput,
SeqCommand, SequencerConfig, SequencerHandle, SequencerSnapshot, SequencerState, TimestampedCommand,
SharedSequencerState, StepSnapshot, TickInput, TickOutput, TimestampedCommand,
}; };

View File

@@ -1,3 +1,5 @@
//! Real-time pattern sequencer: evaluates Forth scripts per step and produces audio/MIDI commands.
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use crossbeam_channel::{bounded, unbounded, Receiver, Sender}; use crossbeam_channel::{bounded, unbounded, Receiver, Sender};
use parking_lot::Mutex; use parking_lot::Mutex;
@@ -132,6 +134,7 @@ pub struct PatternSnapshot {
pub speed: crate::model::PatternSpeed, pub speed: crate::model::PatternSpeed,
pub length: usize, pub length: usize,
pub steps: Vec<StepSnapshot>, pub steps: Vec<StepSnapshot>,
#[allow(dead_code)]
pub quantization: LaunchQuantization, pub quantization: LaunchQuantization,
pub sync_mode: SyncMode, pub sync_mode: SyncMode,
pub follow_up: FollowUp, pub follow_up: FollowUp,
@@ -164,7 +167,6 @@ pub struct SharedSequencerState {
pub beat: f64, pub beat: f64,
} }
#[allow(dead_code)]
pub struct SequencerSnapshot { pub struct SequencerSnapshot {
pub active_patterns: Vec<ActivePatternState>, pub active_patterns: Vec<ActivePatternState>,
step_traces: Arc<StepTracesMap>, step_traces: Arc<StepTracesMap>,

View File

@@ -26,6 +26,7 @@ pub struct InitArgs {
pub buffer: Option<u32>, pub buffer: Option<u32>,
} }
// Fields destructured in main.rs (cli) and plugin crate — all are used.
#[allow(dead_code)] #[allow(dead_code)]
pub struct Init { pub struct Init {
pub app: App, pub app: App,

View File

@@ -1,3 +1,5 @@
//! Keyboard and mouse input handling — dispatches events to page-specific or modal handlers.
pub(crate) mod engine_page; pub(crate) mod engine_page;
mod help_page; mod help_page;
mod main_page; mod main_page;

View File

@@ -12,9 +12,12 @@ use crate::state::{
pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
match &mut ctx.app.ui.modal { match &mut ctx.app.ui.modal {
Modal::Confirm { action, selected } => { Modal::Confirm { action, selected } => {
let (action, confirmed) = (action.clone(), *selected); let confirmed = *selected;
match key.code { match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => return execute_confirm(ctx, &action), KeyCode::Char('y') | KeyCode::Char('Y') => {
let action = action.clone();
return execute_confirm(ctx, &action);
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
ctx.dispatch(AppCommand::CloseModal); ctx.dispatch(AppCommand::CloseModal);
} }
@@ -25,6 +28,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
} }
KeyCode::Enter => { KeyCode::Enter => {
if confirmed { if confirmed {
let action = action.clone();
return execute_confirm(ctx, &action); return execute_confirm(ctx, &action);
} }
ctx.dispatch(AppCommand::CloseModal); ctx.dispatch(AppCommand::CloseModal);
@@ -35,12 +39,12 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
Modal::FileBrowser(state) => match key.code { Modal::FileBrowser(state) => match key.code {
KeyCode::Enter => { KeyCode::Enter => {
use crate::state::file_browser::FileBrowserMode; use crate::state::file_browser::FileBrowserMode;
let mode = state.mode.clone(); let is_save = matches!(state.mode, FileBrowserMode::Save);
if let Some(path) = state.confirm() { if let Some(path) = state.confirm() {
ctx.dispatch(AppCommand::CloseModal); ctx.dispatch(AppCommand::CloseModal);
match mode { if is_save {
FileBrowserMode::Save => ctx.dispatch(AppCommand::Save(path)), ctx.dispatch(AppCommand::Save(path));
FileBrowserMode::Load => { } else {
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll); let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
let _ = ctx.seq_cmd_tx.send(SeqCommand::ResetScriptState); let _ = ctx.seq_cmd_tx.send(SeqCommand::ResetScriptState);
ctx.dispatch(AppCommand::Load(path)); ctx.dispatch(AppCommand::Load(path));
@@ -48,7 +52,6 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
} }
} }
} }
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Tab => state.autocomplete(), KeyCode::Tab => state.autocomplete(),
KeyCode::Left => state.go_up(), KeyCode::Left => state.go_up(),
@@ -63,7 +66,6 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
_ => {} _ => {}
}, },
Modal::Rename { target, name } => { Modal::Rename { target, name } => {
let target = target.clone();
match key.code { match key.code {
KeyCode::Enter => { KeyCode::Enter => {
let new_name = if name.trim().is_empty() { let new_name = if name.trim().is_empty() {
@@ -71,6 +73,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
} else { } else {
Some(name.clone()) Some(name.clone())
}; };
let target = target.clone();
ctx.dispatch(rename_command(&target, new_name)); ctx.dispatch(rename_command(&target, new_name));
ctx.dispatch(AppCommand::CloseModal); ctx.dispatch(AppCommand::CloseModal);
} }

View File

@@ -12,3 +12,7 @@ pub use cagire_project::{
MAX_BANKS, MAX_PATTERNS, MAX_BANKS, MAX_PATTERNS,
}; };
pub use script::ScriptEngine; pub use script::ScriptEngine;
pub fn bp_label(bank: usize, pattern: usize) -> String {
format!("B{:02}:P{:02}", bank + 1, pattern + 1)
}

View File

@@ -117,17 +117,14 @@ pub fn copy_steps(
bank: usize, bank: usize,
pattern: usize, pattern: usize,
indices: &[usize], indices: &[usize],
) -> (CopiedSteps, Vec<String>) { ) -> CopiedSteps {
let pat = project.pattern_at(bank, pattern); let pat = project.pattern_at(bank, pattern);
let mut steps = Vec::new(); let mut steps = Vec::new();
let mut scripts = Vec::new();
for &idx in indices { for &idx in indices {
if let Some(step) = pat.step(idx) { if let Some(step) = pat.step(idx) {
let resolved = pat.resolve_script(idx).unwrap_or("").to_string();
scripts.push(resolved.clone());
steps.push(CopiedStepData { steps.push(CopiedStepData {
script: resolved, script: pat.resolve_script(idx).unwrap_or("").to_string(),
active: step.active, active: step.active,
source: step.source, source: step.source,
original_index: idx, original_index: idx,
@@ -136,12 +133,11 @@ pub fn copy_steps(
} }
} }
let copied = CopiedSteps { CopiedSteps {
bank, bank,
pattern, pattern,
steps, steps,
}; }
(copied, scripts)
} }
pub struct PasteResult { pub struct PasteResult {

View File

@@ -1,4 +1,4 @@
use crate::model::{FollowUp, LaunchQuantization, PatternSpeed, SyncMode}; use crate::model::{self, FollowUp, LaunchQuantization, PatternSpeed, SyncMode};
use crate::state::editor::{EuclideanField, PatternField, PatternPropsField}; use crate::state::editor::{EuclideanField, PatternField, PatternPropsField};
use crate::state::file_browser::FileBrowserState; use crate::state::file_browser::FileBrowserState;
@@ -41,7 +41,7 @@ impl RenameTarget {
pub fn title(&self) -> String { pub fn title(&self) -> String {
match self { match self {
Self::Bank { bank } => format!("Rename Bank {:02}", bank + 1), Self::Bank { bank } => format!("Rename Bank {:02}", bank + 1),
Self::Pattern { bank, pattern } => format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1), Self::Pattern { bank, pattern } => format!("Rename {}", model::bp_label(*bank, *pattern)),
Self::Step { step, .. } => format!("Name Step {:02}", step + 1), Self::Step { step, .. } => format!("Name Step {:02}", step + 1),
} }
} }

View File

@@ -9,7 +9,7 @@ use crate::theme;
static EMPTY_SET: LazyLock<HashSet<String>> = LazyLock::new(HashSet::new); static EMPTY_SET: LazyLock<HashSet<String>> = LazyLock::new(HashSet::new);
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum TokenKind { enum TokenKind {
Number, Number,
String, String,
Comment, Comment,
@@ -65,11 +65,11 @@ impl TokenKind {
} }
} }
pub struct Token { struct Token {
pub start: usize, start: usize,
pub end: usize, end: usize,
pub kind: TokenKind, kind: TokenKind,
pub varargs: bool, varargs: bool,
} }
fn lookup_word_kind(word: &str) -> Option<(TokenKind, bool)> { fn lookup_word_kind(word: &str) -> Option<(TokenKind, bool)> {
@@ -121,7 +121,7 @@ const INTERVALS: &[&str] = &[
"M14", "P15", "M14", "P15",
]; ];
pub fn tokenize_line(line: &str, user_words: &HashSet<String>) -> Vec<Token> { fn tokenize_line(line: &str, user_words: &HashSet<String>) -> Vec<Token> {
let mut tokens = Vec::new(); let mut tokens = Vec::new();
let mut chars = line.char_indices().peekable(); let mut chars = line.char_indices().peekable();

View File

@@ -1,18 +1,17 @@
//! Main page view — sequencer grid, visualizations (scope/spectrum), script previews.
use std::collections::HashSet; use std::collections::HashSet;
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame; use ratatui::Frame;
use crate::app::App; use crate::app::App;
use crate::engine::SequencerSnapshot; use crate::engine::SequencerSnapshot;
use crate::model::SourceSpan;
use crate::state::MainLayout; use crate::state::MainLayout;
use crate::theme; use crate::theme;
use crate::views::highlight::highlight_line_with_runtime; use crate::views::render::highlight_script_lines;
use crate::views::render::{adjust_resolved_for_line, adjust_spans_for_line};
use crate::widgets::{Orientation, Scope, Spectrum, VuMeter}; use crate::widgets::{Orientation, Scope, Spectrum, VuMeter};
pub fn layout(area: Rect) -> [Rect; 3] { pub fn layout(area: Rect) -> [Rect; 3] {
@@ -70,14 +69,15 @@ fn render_top_layout(
idx += 1; idx += 1;
} }
if has_preview { if has_preview {
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
let has_prelude = !app.project_state.project.prelude.trim().is_empty(); let has_prelude = !app.project_state.project.prelude.trim().is_empty();
if has_prelude { if has_prelude {
let [script_area, prelude_area] = let [script_area, prelude_area] =
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(areas[idx]); Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(areas[idx]);
render_script_preview(frame, app, snapshot, script_area); render_script_preview(frame, app, snapshot, &user_words, script_area);
render_prelude_preview(frame, app, prelude_area); render_prelude_preview(frame, app, &user_words, prelude_area);
} else { } else {
render_script_preview(frame, app, snapshot, areas[idx]); render_script_preview(frame, app, snapshot, &user_words, areas[idx]);
} }
idx += 1; idx += 1;
} }
@@ -163,11 +163,18 @@ fn render_viz_area(
Orientation::Horizontal Orientation::Horizontal
}; };
let user_words_once: Option<HashSet<String>> = if panels.iter().any(|p| matches!(p, VizPanel::Preview)) {
Some(app.dict.lock().keys().cloned().collect())
} else {
None
};
for (panel, panel_area) in panels.iter().zip(areas.iter()) { for (panel, panel_area) in panels.iter().zip(areas.iter()) {
match panel { match panel {
VizPanel::Scope => render_scope(frame, app, *panel_area, orientation), VizPanel::Scope => render_scope(frame, app, *panel_area, orientation),
VizPanel::Spectrum => render_spectrum(frame, app, *panel_area), VizPanel::Spectrum => render_spectrum(frame, app, *panel_area),
VizPanel::Preview => { VizPanel::Preview => {
let user_words = user_words_once.as_ref().unwrap();
let has_prelude = !app.project_state.project.prelude.trim().is_empty(); let has_prelude = !app.project_state.project.prelude.trim().is_empty();
if has_prelude { if has_prelude {
let [script_area, prelude_area] = if is_vertical_layout { let [script_area, prelude_area] = if is_vertical_layout {
@@ -177,10 +184,10 @@ fn render_viz_area(
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]) Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)])
.areas(*panel_area) .areas(*panel_area)
}; };
render_script_preview(frame, app, snapshot, script_area); render_script_preview(frame, app, snapshot, user_words, script_area);
render_prelude_preview(frame, app, prelude_area); render_prelude_preview(frame, app, user_words, prelude_area);
} else { } else {
render_script_preview(frame, app, snapshot, *panel_area); render_script_preview(frame, app, snapshot, user_words, *panel_area);
} }
} }
} }
@@ -488,10 +495,10 @@ fn render_script_preview(
frame: &mut Frame, frame: &mut Frame,
app: &App, app: &App,
snapshot: &SequencerSnapshot, snapshot: &SequencerSnapshot,
user_words: &HashSet<String>,
area: Rect, area: Rect,
) { ) {
let theme = theme::get(); let theme = theme::get();
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
let pattern = app.current_edit_pattern(); let pattern = app.current_edit_pattern();
let step_idx = app.editor_ctx.step; let step_idx = app.editor_ctx.step;
@@ -534,43 +541,17 @@ fn render_script_preview(
None None
}; };
let resolved_display: Vec<(SourceSpan, String)> = trace let lines = highlight_script_lines(script, trace, user_words, inner.height as usize);
.map(|t| {
t.resolved
.iter()
.map(|(s, v)| (*s, v.display()))
.collect()
})
.unwrap_or_default();
let mut line_start = 0usize;
let lines: Vec<Line> = script
.lines()
.take(inner.height as usize)
.map(|line_str| {
let tokens = if let Some(t) = trace {
let exec = adjust_spans_for_line(&t.executed_spans, line_start, line_str.len());
let sel = adjust_spans_for_line(&t.selected_spans, line_start, line_str.len());
let res = adjust_resolved_for_line(&resolved_display, line_start, line_str.len());
highlight_line_with_runtime(line_str, &exec, &sel, &res, &user_words)
} else {
highlight_line_with_runtime(line_str, &[], &[], &[], &user_words)
};
line_start += line_str.len() + 1;
let spans: Vec<Span> = tokens
.into_iter()
.map(|(style, text, _)| Span::styled(text, style))
.collect();
Line::from(spans)
})
.collect();
frame.render_widget(Paragraph::new(lines), inner); frame.render_widget(Paragraph::new(lines), inner);
} }
fn render_prelude_preview(frame: &mut Frame, app: &App, area: Rect) { fn render_prelude_preview(
frame: &mut Frame,
app: &App,
user_words: &HashSet<String>,
area: Rect,
) {
let theme = theme::get(); let theme = theme::get();
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
let prelude = &app.project_state.project.prelude; let prelude = &app.project_state.project.prelude;
let block = Block::default() let block = Block::default()
@@ -580,19 +561,7 @@ fn render_prelude_preview(frame: &mut Frame, app: &App, area: Rect) {
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
let lines: Vec<Line> = prelude let lines = highlight_script_lines(prelude, None, user_words, inner.height as usize);
.lines()
.take(inner.height as usize)
.map(|line_str| {
let tokens = highlight_line_with_runtime(line_str, &[], &[], &[], &user_words);
let spans: Vec<Span> = tokens
.into_iter()
.map(|(style, text, _)| Span::styled(text, style))
.collect();
Line::from(spans)
})
.collect();
frame.render_widget(Paragraph::new(lines), inner); frame.render_widget(Paragraph::new(lines), inner);
} }

View File

@@ -1,3 +1,5 @@
//! Top-level render dispatch — composes header, page views, modals, and effects each frame.
use std::collections::HashSet; use std::collections::HashSet;
use std::time::Duration; use std::time::Duration;
@@ -9,7 +11,7 @@ use ratatui::Frame;
use crate::app::App; use crate::app::App;
use crate::engine::{LinkState, SequencerSnapshot}; use crate::engine::{LinkState, SequencerSnapshot};
use crate::model::SourceSpan; use crate::model::{ExecutionTrace, SourceSpan};
use crate::page::Page; use crate::page::Page;
use crate::state::{ use crate::state::{
EditorTarget, EuclideanField, FlashKind, Modal, PanelFocus, PatternField, RenameTarget, EditorTarget, EuclideanField, FlashKind, Modal, PanelFocus, PatternField, RenameTarget,
@@ -62,6 +64,44 @@ pub fn adjust_resolved_for_line(
.collect() .collect()
} }
pub fn highlight_script_lines(
script: &str,
trace: Option<&ExecutionTrace>,
user_words: &HashSet<String>,
max_lines: usize,
) -> Vec<Line<'static>> {
let resolved_display: Vec<(SourceSpan, String)> = trace
.map(|t| {
t.resolved
.iter()
.map(|(s, v)| (*s, v.display()))
.collect()
})
.unwrap_or_default();
let mut line_start = 0usize;
script
.lines()
.take(max_lines)
.map(|line_str| {
let tokens = if let Some(t) = trace {
let exec = adjust_spans_for_line(&t.executed_spans, line_start, line_str.len());
let sel = adjust_spans_for_line(&t.selected_spans, line_start, line_str.len());
let res = adjust_resolved_for_line(&resolved_display, line_start, line_str.len());
highlight_line_with_runtime(line_str, &exec, &sel, &res, user_words)
} else {
highlight_line_with_runtime(line_str, &[], &[], &[], user_words)
};
line_start += line_str.len() + 1;
let spans: Vec<Span> = tokens
.into_iter()
.map(|(style, text, _)| Span::styled(text, style))
.collect();
Line::from(spans)
})
.collect()
}
pub fn horizontal_padding(width: u16) -> u16 { pub fn horizontal_padding(width: u16) -> u16 {
if width >= 120 { if width >= 120 {
4 4
@@ -522,7 +562,6 @@ fn render_modal(
term: Rect, term: Rect,
) -> Option<Rect> { ) -> Option<Rect> {
let theme = theme::get(); let theme = theme::get();
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
let inner = match &app.ui.modal { let inner = match &app.ui.modal {
Modal::None => return None, Modal::None => return None,
Modal::Confirm { action, selected } => { Modal::Confirm { action, selected } => {
@@ -598,8 +637,14 @@ fn render_modal(
.height(18) .height(18)
.render_centered(frame, term) .render_centered(frame, term)
} }
Modal::Preview => render_modal_preview(frame, app, snapshot, &user_words, term), Modal::Preview => {
Modal::Editor => render_modal_editor(frame, app, snapshot, &user_words, term), let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
render_modal_preview(frame, app, snapshot, &user_words, term)
}
Modal::Editor => {
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
render_modal_editor(frame, app, snapshot, &user_words, term)
}
Modal::PatternProps { Modal::PatternProps {
bank, bank,
pattern, pattern,
@@ -859,34 +904,8 @@ fn render_modal_preview(
None None
}; };
let resolved_display: Vec<(SourceSpan, String)> = trace let lines = highlight_script_lines(script, trace, user_words, usize::MAX);
.map(|t| t.resolved.iter().map(|(s, v)| (*s, v.display())).collect()) frame.render_widget(Paragraph::new(lines), inner);
.unwrap_or_default();
let mut line_start = 0usize;
let lines: Vec<Line> = script
.lines()
.map(|line_str| {
let tokens = if let Some(t) = trace {
let exec = adjust_spans_for_line(&t.executed_spans, line_start, line_str.len());
let sel = adjust_spans_for_line(&t.selected_spans, line_start, line_str.len());
let res =
adjust_resolved_for_line(&resolved_display, line_start, line_str.len());
highlight_line_with_runtime(line_str, &exec, &sel, &res, user_words)
} else {
highlight_line_with_runtime(line_str, &[], &[], &[], user_words)
};
line_start += line_str.len() + 1;
let spans: Vec<Span> = tokens
.into_iter()
.map(|(style, text, _)| Span::styled(text, style))
.collect();
Line::from(spans)
})
.collect();
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner);
} }
inner inner