6 Commits

Author SHA1 Message Date
e4799c1f42 Merge branch 'main' of github.com:Bubobubobubobubo/cagire
Some checks failed
Deploy Website / deploy (push) Failing after 4m49s
2026-02-02 19:12:37 +01:00
636129688d lookahead 2026-02-02 19:12:32 +01:00
a2ee0e5a50 Fix: Copy register handling for cagire-desktop (Linux) 2026-02-02 18:25:02 +01:00
96ed74c6fe Fix: CPAL version mismatch 2026-02-02 18:08:55 +01:00
a67d982fcd Pattern mute and so on 2026-02-02 16:27:11 +01:00
c9ab7a4f0b chore: Release 2026-02-02 13:44:47 +01:00
32 changed files with 988 additions and 104 deletions

View File

@@ -2,6 +2,12 @@
All notable changes to this project will be documented in this file.
## [0.0.5] - Unreleased
### Added
- Mute/solo for patterns: stage with `m`/`x`, commit with `c`. Solo mutes all other patterns. Clear with `M`/`X`.
- Lookahead scheduling: scripts are pre-evaluated ahead of time and audio commands are scheduled at precise beat positions, improving timing accuracy under CPU load.
## [0.0.4] - 2026-02-02
### Added
@@ -19,6 +25,8 @@ All notable changes to this project will be documented in this file.
### Fixed
- Scope/spectrum ratio asymmetry in Left/Right layout modes.
- Updated `cpal` dependency from 0.15 to 0.17 to fix type mismatch with `doux` audio backend.
- Copy/paste (Ctrl+C/V/X) not working in desktop version due to egui intercepting clipboard shortcuts.
## [0.0.3] - 2026-02-02

View File

@@ -2,7 +2,7 @@
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"]
[workspace.package]
version = "0.0.3"
version = "0.0.4"
edition = "2021"
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
license = "AGPL-3.0"
@@ -53,7 +53,7 @@ doux = { git = "https://github.com/sova-org/doux", features = ["native"] }
rusty_link = "0.4"
ratatui = "0.30"
crossterm = "0.29"
cpal = "0.15"
cpal = "0.17"
clap = { version = "4", features = ["derive"] }
rand = "0.8"
serde = { version = "1", features = ["derive"] }

View File

@@ -3,8 +3,17 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum MuteStatus {
Normal,
Muted,
Soloed,
EffectivelyMuted, // Solo active on another pattern
}
pub struct ActivePatterns<'a> {
patterns: &'a [(usize, usize, usize)], // (bank, pattern, iter)
mute_status: Option<&'a [MuteStatus]>,
current_step: Option<(usize, usize)>, // (current_step, total_steps)
}
@@ -12,6 +21,7 @@ impl<'a> ActivePatterns<'a> {
pub fn new(patterns: &'a [(usize, usize, usize)]) -> Self {
Self {
patterns,
mute_status: None,
current_step: None,
}
}
@@ -20,6 +30,11 @@ impl<'a> ActivePatterns<'a> {
self.current_step = Some((current, total));
self
}
pub fn with_mute_status(mut self, status: &'a [MuteStatus]) -> Self {
self.mute_status = Some(status);
self
}
}
impl Widget for ActivePatterns<'_> {
@@ -39,20 +54,36 @@ impl Widget for ActivePatterns<'_> {
if row >= max_pattern_rows {
break;
}
let text = format!("B{:02}:{:02} ({:02})", bank + 1, pattern + 1, iter.min(99));
let y = area.y + row as u16;
let bg = if row % 2 == 0 {
theme.table.row_even
} else {
theme.table.row_odd
let mute_status = self
.mute_status
.and_then(|s| s.get(row))
.copied()
.unwrap_or(MuteStatus::Normal);
let (prefix, fg, bg) = match mute_status {
MuteStatus::Soloed => ("S", theme.list.soloed_fg, theme.list.soloed_bg),
MuteStatus::Muted => ("M", theme.list.muted_fg, theme.list.muted_bg),
MuteStatus::EffectivelyMuted => (" ", theme.list.muted_fg, theme.list.muted_bg),
MuteStatus::Normal => {
let bg = if row % 2 == 0 {
theme.table.row_even
} else {
theme.table.row_odd
};
(" ", theme.ui.text_primary, bg)
}
};
let text = format!("{}B{:02}:{:02}({:02})", prefix, bank + 1, pattern + 1, iter.min(99));
let y = area.y + row as u16;
let mut chars = text.chars();
for col in 0..area.width as usize {
let ch = chars.next().unwrap_or(' ');
buf[(area.x + col as u16, y)]
.set_char(ch)
.set_fg(theme.ui.text_primary)
.set_fg(fg)
.set_bg(bg);
}
}

View File

@@ -13,7 +13,7 @@ mod text_input;
pub mod theme;
mod vu_meter;
pub use active_patterns::ActivePatterns;
pub use active_patterns::{ActivePatterns, MuteStatus};
pub use confirm::ConfirmModal;
pub use editor::{CompletionCandidate, Editor};
pub use file_browser::FileBrowserModal;

View File

@@ -123,6 +123,10 @@ pub fn theme() -> ThemeColors {
edit_fg: teal,
hover_bg: surface1,
hover_fg: text,
muted_bg: Color::Rgb(215, 215, 225),
muted_fg: overlay0,
soloed_bg: Color::Rgb(250, 235, 200),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: red,

View File

@@ -123,6 +123,10 @@ pub fn theme() -> ThemeColors {
edit_fg: teal,
hover_bg: surface1,
hover_fg: text,
muted_bg: Color::Rgb(40, 40, 50),
muted_fg: overlay0,
soloed_bg: Color::Rgb(60, 55, 35),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: red,

View File

@@ -117,6 +117,10 @@ pub fn theme() -> ThemeColors {
edit_fg: cyan,
hover_bg: lighter_bg,
hover_fg: foreground,
muted_bg: Color::Rgb(50, 52, 65),
muted_fg: comment,
soloed_bg: Color::Rgb(70, 70, 50),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: red,

View File

@@ -118,6 +118,10 @@ pub fn theme() -> ThemeColors {
edit_fg: mint,
hover_bg: bg_lighter,
hover_fg: fg,
muted_bg: Color::Rgb(75, 70, 95),
muted_fg: muted,
soloed_bg: Color::Rgb(100, 95, 65),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: coral,

View File

@@ -119,6 +119,10 @@ pub fn theme() -> ThemeColors {
edit_fg: aqua,
hover_bg: bg2,
hover_fg: fg,
muted_bg: Color::Rgb(50, 50, 55),
muted_fg: fg4,
soloed_bg: Color::Rgb(70, 65, 40),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: red,

View File

@@ -114,6 +114,10 @@ pub fn theme() -> ThemeColors {
edit_fg: yellow,
hover_bg: Color::Rgb(230, 50, 50),
hover_fg: yellow,
muted_bg: darker_red,
muted_fg: dim_yellow,
soloed_bg: Color::Rgb(200, 200, 0),
soloed_fg: black,
},
link_status: LinkStatusColors {
disabled: white,

View File

@@ -119,6 +119,10 @@ pub fn theme() -> ThemeColors {
edit_fg: crystal_blue,
hover_bg: bg_lighter,
hover_fg: fg,
muted_bg: Color::Rgb(38, 38, 48),
muted_fg: comment,
soloed_bg: Color::Rgb(60, 55, 45),
soloed_fg: carp_yellow,
},
link_status: LinkStatusColors {
disabled: autumn_red,

View File

@@ -187,6 +187,10 @@ pub struct ListColors {
pub edit_fg: Color,
pub hover_bg: Color,
pub hover_fg: Color,
pub muted_bg: Color,
pub muted_fg: Color,
pub soloed_bg: Color,
pub soloed_fg: Color,
}
#[derive(Clone)]

View File

@@ -116,6 +116,10 @@ pub fn theme() -> ThemeColors {
edit_fg: bright,
hover_bg: surface2,
hover_fg: fg,
muted_bg: Color::Rgb(22, 22, 22),
muted_fg: dark,
soloed_bg: Color::Rgb(60, 60, 60),
soloed_fg: bright,
},
link_status: LinkStatusColors {
disabled: dim,

View File

@@ -116,6 +116,10 @@ pub fn theme() -> ThemeColors {
edit_fg: dark,
hover_bg: surface2,
hover_fg: fg,
muted_bg: Color::Rgb(235, 235, 235),
muted_fg: light,
soloed_bg: Color::Rgb(190, 190, 190),
soloed_fg: dark,
},
link_status: LinkStatusColors {
disabled: dim,

View File

@@ -117,6 +117,10 @@ pub fn theme() -> ThemeColors {
edit_fg: blue,
hover_bg: bg_lighter,
hover_fg: fg,
muted_bg: Color::Rgb(48, 50, 45),
muted_fg: comment,
soloed_bg: Color::Rgb(70, 65, 45),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: pink,

View File

@@ -117,6 +117,10 @@ pub fn theme() -> ThemeColors {
edit_fg: frost0,
hover_bg: polar_night2,
hover_fg: snow_storm2,
muted_bg: Color::Rgb(55, 60, 70),
muted_fg: polar_night3,
soloed_bg: Color::Rgb(70, 65, 50),
soloed_fg: aurora_yellow,
},
link_status: LinkStatusColors {
disabled: aurora_red,

View File

@@ -118,6 +118,10 @@ pub fn theme() -> ThemeColors {
edit_fg: cyan,
hover_bg: surface2,
hover_fg: fg,
muted_bg: Color::Rgb(15, 15, 15),
muted_fg: fg_muted,
soloed_bg: Color::Rgb(45, 40, 15),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: red,

View File

@@ -118,6 +118,10 @@ pub fn theme() -> ThemeColors {
edit_fg: foam,
hover_bg: bg_lighter,
hover_fg: fg,
muted_bg: Color::Rgb(32, 30, 42),
muted_fg: muted,
soloed_bg: Color::Rgb(60, 50, 40),
soloed_fg: gold,
},
link_status: LinkStatusColors {
disabled: love,

View File

@@ -118,6 +118,10 @@ pub fn theme() -> ThemeColors {
edit_fg: blue,
hover_bg: bg_lighter,
hover_fg: fg,
muted_bg: Color::Rgb(35, 38, 50),
muted_fg: comment,
soloed_bg: Color::Rgb(60, 55, 40),
soloed_fg: yellow,
},
link_status: LinkStatusColors {
disabled: red,

View File

@@ -183,6 +183,10 @@ pub fn rotate_theme(theme: ThemeColors, degrees: f32) -> ThemeColors {
edit_fg: rotate_color(theme.list.edit_fg, degrees),
hover_bg: rotate_color(theme.list.hover_bg, degrees),
hover_fg: rotate_color(theme.list.hover_fg, degrees),
muted_bg: rotate_color(theme.list.muted_bg, degrees),
muted_fg: rotate_color(theme.list.muted_fg, degrees),
soloed_bg: rotate_color(theme.list.soloed_bg, degrees),
soloed_fg: rotate_color(theme.list.soloed_fg, degrees),
},
link_status: LinkStatusColors {
disabled: rotate_color(theme.link_status.disabled, degrees),

View File

@@ -17,8 +17,8 @@ use crate::services::pattern_editor;
use crate::settings::Settings;
use crate::state::{
AudioSettings, CyclicEnum, DictFocus, EditorContext, FlashKind, LiveKeyState, Metrics, Modal,
OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, PlaybackState,
ProjectState, StagedChange, UiState,
MuteState, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav,
PlaybackState, ProjectState, StagedChange, UiState,
};
use crate::views::{dict_view, help_view};
@@ -28,6 +28,7 @@ pub struct App {
pub project_state: ProjectState,
pub ui: UiState,
pub playback: PlaybackState,
pub mute: MuteState,
pub page: Page,
pub editor_ctx: EditorContext,
@@ -69,6 +70,7 @@ impl App {
project_state: ProjectState::default(),
ui: UiState::default(),
playback: PlaybackState::default(),
mute: MuteState::default(),
page: Page::default(),
editor_ctx: EditorContext::default(),
@@ -494,26 +496,64 @@ impl App {
}
}
pub fn commit_staged_changes(&mut self) {
if self.playback.staged_changes.is_empty() {
/// Commits staged pattern and mute/solo changes.
/// Returns true if mute state changed (caller should send to sequencer).
pub fn commit_staged_changes(&mut self) -> bool {
let pattern_count = self.playback.staged_changes.len();
let mute_count = self.playback.staged_mute_changes.len();
if pattern_count == 0 && mute_count == 0 {
self.ui.set_status("No changes to commit".to_string());
return;
return false;
}
let count = self.playback.staged_changes.len();
self.playback
.queued_changes
.append(&mut self.playback.staged_changes);
self.ui.set_status(format!("Committed {count} changes"));
// Commit pattern changes (queued for quantization)
if pattern_count > 0 {
self.playback
.queued_changes
.append(&mut self.playback.staged_changes);
}
// Apply mute/solo changes immediately
let mute_changed = mute_count > 0;
for change in self.playback.staged_mute_changes.drain() {
match change {
crate::state::StagedMuteChange::ToggleMute { bank, pattern } => {
self.mute.toggle_mute(bank, pattern);
}
crate::state::StagedMuteChange::ToggleSolo { bank, pattern } => {
self.mute.toggle_solo(bank, pattern);
}
}
}
let status = match (pattern_count, mute_count) {
(0, m) => format!("Applied {m} mute/solo changes"),
(p, 0) => format!("Committed {p} pattern changes"),
(p, m) => format!("Committed {p} pattern + {m} mute/solo changes"),
};
self.ui.set_status(status);
mute_changed
}
pub fn clear_staged_changes(&mut self) {
if self.playback.staged_changes.is_empty() {
let pattern_count = self.playback.staged_changes.len();
let mute_count = self.playback.staged_mute_changes.len();
if pattern_count == 0 && mute_count == 0 {
return;
}
let count = self.playback.staged_changes.len();
self.playback.staged_changes.clear();
self.ui
.set_status(format!("Cleared {count} staged changes"));
self.playback.staged_mute_changes.clear();
let status = match (pattern_count, mute_count) {
(0, m) => format!("Cleared {m} staged mute/solo"),
(p, 0) => format!("Cleared {p} staged patterns"),
(p, m) => format!("Cleared {p} patterns + {m} mute/solo"),
};
self.ui.set_status(status);
}
pub fn select_edit_pattern(&mut self, pattern: usize) {
@@ -1094,9 +1134,6 @@ impl App {
AppCommand::DuplicateSteps => self.duplicate_steps(link),
// Pattern playback (staging)
AppCommand::CommitStagedChanges => {
self.commit_staged_changes();
}
AppCommand::ClearStagedChanges => {
self.clear_staged_changes();
}
@@ -1300,6 +1337,22 @@ impl App {
self.stage_pattern_toggle(bank, pattern, snapshot);
}
// Mute/Solo (staged)
AppCommand::StageMute { bank, pattern } => {
self.playback.stage_mute(bank, pattern);
}
AppCommand::StageSolo { bank, pattern } => {
self.playback.stage_solo(bank, pattern);
}
AppCommand::ClearMutes => {
self.playback.clear_staged_mutes();
self.mute.clear_mute();
}
AppCommand::ClearSolos => {
self.playback.clear_staged_solos();
self.mute.clear_solo();
}
// UI state
AppCommand::ClearMinimap => {
self.ui.minimap_until = None;
@@ -1522,6 +1575,13 @@ impl App {
}
}
pub fn send_mute_state(&self, cmd_tx: &Sender<SeqCommand>) {
let _ = cmd_tx.send(SeqCommand::SetMuteState {
muted: self.mute.muted.clone(),
soloed: self.mute.soloed.clone(),
});
}
pub fn flush_dirty_patterns(&mut self, cmd_tx: &Sender<SeqCommand>) {
for (bank, pattern) in self.project_state.take_dirty() {
let pat = self.project_state.project.pattern_at(bank, pattern);

View File

@@ -75,7 +75,6 @@ pub enum AppCommand {
DuplicateSteps,
// Pattern playback (staging)
CommitStagedChanges,
ClearStagedChanges,
// Project
@@ -156,6 +155,12 @@ pub enum AppCommand {
PatternsBack,
PatternsTogglePlay,
// Mute/Solo (staged)
StageMute { bank: usize, pattern: usize },
StageSolo { bank: usize, pattern: usize },
ClearMutes, // Clears both staged and applied mutes
ClearSolos, // Clears both staged and applied solos
// UI state
ClearMinimap,
HideTitle,

View File

@@ -257,7 +257,7 @@ pub fn build_stream(
};
let default_config = device.default_output_config().map_err(|e| e.to_string())?;
let sample_rate = default_config.sample_rate().0 as f32;
let sample_rate = default_config.sample_rate() as f32;
let buffer_size = if config.buffer_size > 0 {
cpal::BufferSize::Fixed(config.buffer_size)

View File

@@ -115,6 +115,10 @@ pub enum SeqCommand {
pattern: usize,
quantization: LaunchQuantization,
},
SetMuteState {
muted: std::collections::HashSet<(usize, usize)>,
soloed: std::collections::HashSet<(usize, usize)>,
},
StopAll,
Shutdown,
}
@@ -432,6 +436,25 @@ fn check_quantization_boundary(
type StepKey = (usize, usize, usize);
/// Tracks a step that has been pre-evaluated via lookahead scheduling.
/// Used to prevent duplicate evaluation when the step's actual fire time arrives.
struct ScheduledStep {
target_beat: f64,
tempo_at_schedule: f64,
}
/// An audio command scheduled for future emission.
/// Commands are held here until their target_beat passes, then emitted to the audio engine.
struct PendingCommand {
cmd: TimestampedCommand,
target_beat: f64,
bank: usize,
pattern: usize,
}
/// Key for tracking scheduled steps: (bank, pattern, step_index, beat_int)
type ScheduledStepKey = (usize, usize, usize, i64);
struct RunsCounter {
counts: HashMap<StepKey, usize>,
}
@@ -551,6 +574,12 @@ pub(crate) struct SequencerState {
buf_audio_commands: Vec<TimestampedCommand>,
cc_access: Option<Arc<dyn CcAccess>>,
active_notes: HashMap<(u8, u8, u8), ActiveNote>,
muted: std::collections::HashSet<(usize, usize)>,
soloed: std::collections::HashSet<(usize, usize)>,
// Lookahead scheduling state
scheduled_steps: HashMap<(usize, usize, usize, i64), ScheduledStep>,
pending_commands: Vec<PendingCommand>,
last_tempo: f64,
}
impl SequencerState {
@@ -575,9 +604,25 @@ impl SequencerState {
buf_audio_commands: Vec::new(),
cc_access,
active_notes: HashMap::new(),
muted: std::collections::HashSet::new(),
soloed: std::collections::HashSet::new(),
scheduled_steps: HashMap::new(),
pending_commands: Vec::new(),
last_tempo: 120.0,
}
}
fn is_effectively_muted(&self, bank: usize, pattern: usize) -> bool {
let key = (bank, pattern);
if self.muted.contains(&key) {
return true;
}
if !self.soloed.is_empty() && !self.soloed.contains(&key) {
return true;
}
false
}
fn process_commands(&mut self, commands: Vec<SeqCommand>) {
for cmd in commands {
match cmd {
@@ -619,6 +664,28 @@ impl SequencerState {
});
}
}
SeqCommand::SetMuteState { muted, soloed } => {
let newly_muted: Vec<(usize, usize)> = self
.audio_state
.active_patterns
.keys()
.filter(|id| {
let key = (id.bank, id.pattern);
let was_muted = self.is_effectively_muted(id.bank, id.pattern);
let now_muted = muted.contains(&key)
|| (!soloed.is_empty() && !soloed.contains(&key));
!was_muted && now_muted
})
.map(|id| (id.bank, id.pattern))
.collect();
self.muted = muted;
self.soloed = soloed;
if !newly_muted.is_empty() {
self.audio_state.flush_midi_notes = true;
}
}
SeqCommand::StopAll => {
self.audio_state.active_patterns.clear();
self.audio_state.pending_starts.clear();
@@ -626,6 +693,8 @@ impl SequencerState {
Arc::make_mut(&mut self.step_traces).clear();
self.runs_counter.counts.clear();
self.audio_state.flush_midi_notes = true;
self.scheduled_steps.clear();
self.pending_commands.clear();
}
SeqCommand::Shutdown => {}
}
@@ -744,12 +813,65 @@ impl SequencerState {
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
bank != pending.id.bank || pattern != pending.id.pattern
});
// Clear scheduled steps and pending commands for this pattern
let (b, p) = (pending.id.bank, pending.id.pattern);
self.scheduled_steps
.retain(|&(bank, pattern, _, _), _| bank != b || pattern != p);
self.pending_commands
.retain(|cmd| cmd.bank != b || cmd.pattern != p);
stopped.push(pending.id);
}
}
stopped
}
/// Convert a logical beat position to engine time for audio scheduling.
fn beat_to_engine_time(
target_beat: f64,
current_beat: f64,
engine_time: f64,
tempo: f64,
) -> f64 {
let beats_ahead = target_beat - current_beat;
let secs_ahead = beats_ahead * 60.0 / tempo;
engine_time + secs_ahead
}
/// Reschedule all pending commands when tempo changes.
fn reschedule_for_tempo_change(
&mut self,
new_tempo: f64,
current_beat: f64,
engine_time: f64,
) {
for pending in &mut self.pending_commands {
if pending.cmd.time.is_some() {
pending.cmd.time = Some(Self::beat_to_engine_time(
pending.target_beat,
current_beat,
engine_time,
new_tempo,
));
}
}
for step in self.scheduled_steps.values_mut() {
step.tempo_at_schedule = new_tempo;
}
self.last_tempo = new_tempo;
}
/// Main step execution with lookahead scheduling support.
///
/// This function handles two timing modes:
/// 1. **Immediate firing**: When a beat boundary is crossed (`beat_int != prev_beat_int`),
/// the current step fires. If already pre-evaluated via lookahead, we skip evaluation.
/// 2. **Lookahead pre-evaluation**: When `lookahead_secs > 0`, we pre-evaluate future steps
/// and queue their commands with precise timestamps for later emission.
///
/// The lookahead scheduling improves timing accuracy by:
/// - Evaluating scripts BEFORE their logical fire time
/// - Scheduling audio commands at exact beat positions using engine time
/// - Allowing the audio engine to play sounds at the precise moment
#[allow(clippy::too_many_arguments)]
fn execute_steps(
&mut self,
@@ -772,6 +894,12 @@ impl SequencerState {
any_step_fired: false,
};
// Reschedule pending commands if tempo changed
if (tempo - self.last_tempo).abs() > 0.001 {
self.reschedule_for_tempo_change(tempo, beat, engine_time);
}
// Load speed overrides from variables
self.speed_overrides.clear();
{
let vars = self.variables.lock().unwrap();
@@ -783,6 +911,15 @@ impl SequencerState {
}
}
let muted_snapshot = self.muted.clone();
let soloed_snapshot = self.soloed.clone();
let lookahead_beats = if tempo > 0.0 {
lookahead_secs * tempo / 60.0
} else {
0.0
};
// Process each active pattern
for (_id, active) in self.audio_state.active_patterns.iter_mut() {
let Some(pattern) = self.pattern_cache.get(active.bank, active.pattern) else {
continue;
@@ -793,76 +930,94 @@ impl SequencerState {
.get(&(active.bank, active.pattern))
.copied()
.unwrap_or_else(|| pattern.speed.multiplier());
let beat_int = (beat * 4.0 * speed_mult).floor() as i64;
let prev_beat_int = (prev_beat * 4.0 * speed_mult).floor() as i64;
let step_fires = beat_int != prev_beat_int && prev_beat >= 0.0;
if beat_int != prev_beat_int && prev_beat >= 0.0 {
// === IMMEDIATE STEP EXECUTION ===
// Fire the current step if a beat boundary was crossed
if step_fires {
result.any_step_fired = true;
let step_idx = active.step_index % pattern.length;
let sched_key: ScheduledStepKey =
(active.bank, active.pattern, step_idx, beat_int);
if let Some(step) = pattern.steps.get(step_idx) {
let resolved_script = pattern.resolve_script(step_idx);
let has_script = resolved_script
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
// Skip evaluation if already done via lookahead
if !self.scheduled_steps.contains_key(&sched_key) {
if let Some(step) = pattern.steps.get(step_idx) {
let resolved_script = pattern.resolve_script(step_idx);
let has_script = resolved_script
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if step.active && has_script {
let source_idx = pattern.resolve_source(step_idx);
let runs = self.runs_counter.get_and_increment(
active.bank,
active.pattern,
source_idx,
);
let ctx = StepContext {
step: step_idx,
beat,
bank: active.bank,
pattern: active.pattern,
tempo,
phase: beat % quantum,
slot: 0,
runs,
iter: active.iter,
speed: speed_mult,
fill,
nudge_secs,
cc_access: self.cc_access.clone(),
#[cfg(feature = "desktop")]
mouse_x,
#[cfg(feature = "desktop")]
mouse_y,
#[cfg(feature = "desktop")]
mouse_down,
};
if let Some(script) = resolved_script {
let mut trace = ExecutionTrace::default();
if let Ok(cmds) = self
.script_engine
.evaluate_with_trace(script, &ctx, &mut trace)
{
Arc::make_mut(&mut self.step_traces).insert(
(active.bank, active.pattern, source_idx),
std::mem::take(&mut trace),
if step.active && has_script {
let pattern_key = (active.bank, active.pattern);
let is_muted = muted_snapshot.contains(&pattern_key)
|| (!soloed_snapshot.is_empty()
&& !soloed_snapshot.contains(&pattern_key));
if !is_muted {
let source_idx = pattern.resolve_source(step_idx);
let runs = self.runs_counter.get_and_increment(
active.bank,
active.pattern,
source_idx,
);
let event_time = if lookahead_secs > 0.0 {
Some(engine_time + lookahead_secs)
} else {
None
let ctx = StepContext {
step: step_idx,
beat,
bank: active.bank,
pattern: active.pattern,
tempo,
phase: beat % quantum,
slot: 0,
runs,
iter: active.iter,
speed: speed_mult,
fill,
nudge_secs,
cc_access: self.cc_access.clone(),
#[cfg(feature = "desktop")]
mouse_x,
#[cfg(feature = "desktop")]
mouse_y,
#[cfg(feature = "desktop")]
mouse_down,
};
for cmd in cmds {
self.event_count += 1;
self.buf_audio_commands.push(TimestampedCommand {
cmd,
time: event_time,
});
if let Some(script) = resolved_script {
let mut trace = ExecutionTrace::default();
if let Ok(cmds) = self
.script_engine
.evaluate_with_trace(script, &ctx, &mut trace)
{
Arc::make_mut(&mut self.step_traces).insert(
(active.bank, active.pattern, source_idx),
std::mem::take(&mut trace),
);
let event_time = if lookahead_secs > 0.0 {
Some(engine_time + lookahead_secs)
} else {
None
};
for cmd in cmds {
self.event_count += 1;
self.buf_audio_commands.push(TimestampedCommand {
cmd,
time: event_time,
});
}
}
}
}
}
}
}
// Advance step index
let next_step = active.step_index + 1;
if next_step >= pattern.length {
active.iter += 1;
@@ -873,8 +1028,144 @@ impl SequencerState {
}
active.step_index = next_step % pattern.length;
}
// === LOOKAHEAD PRE-EVALUATION ===
// Pre-evaluate future steps within the lookahead window
if lookahead_secs > 0.0 {
let future_beat = beat + lookahead_beats;
let future_beat_int = (future_beat * 4.0 * speed_mult).floor() as i64;
let start_beat_int = beat_int + 1;
let mut lookahead_step = active.step_index;
let mut lookahead_iter = active.iter;
for target_beat_int in start_beat_int..=future_beat_int {
let step_idx = lookahead_step % pattern.length;
let sched_key: ScheduledStepKey =
(active.bank, active.pattern, step_idx, target_beat_int);
// Skip if already scheduled
if self.scheduled_steps.contains_key(&sched_key) {
let next = lookahead_step + 1;
if next >= pattern.length {
lookahead_iter += 1;
}
lookahead_step = next % pattern.length;
continue;
}
// Calculate the logical beat time for this step
let target_beat = target_beat_int as f64 / (4.0 * speed_mult);
if let Some(step) = pattern.steps.get(step_idx) {
let resolved_script = pattern.resolve_script(step_idx);
let has_script = resolved_script
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if step.active && has_script {
let pattern_key = (active.bank, active.pattern);
let is_muted = muted_snapshot.contains(&pattern_key)
|| (!soloed_snapshot.is_empty()
&& !soloed_snapshot.contains(&pattern_key));
if !is_muted {
let source_idx = pattern.resolve_source(step_idx);
let runs = self.runs_counter.get_and_increment(
active.bank,
active.pattern,
source_idx,
);
let ctx = StepContext {
step: step_idx,
beat: target_beat,
bank: active.bank,
pattern: active.pattern,
tempo,
phase: target_beat % quantum,
slot: 0,
runs,
iter: lookahead_iter,
speed: speed_mult,
fill,
nudge_secs,
cc_access: self.cc_access.clone(),
#[cfg(feature = "desktop")]
mouse_x,
#[cfg(feature = "desktop")]
mouse_y,
#[cfg(feature = "desktop")]
mouse_down,
};
if let Some(script) = resolved_script {
let mut trace = ExecutionTrace::default();
if let Ok(cmds) = self
.script_engine
.evaluate_with_trace(script, &ctx, &mut trace)
{
Arc::make_mut(&mut self.step_traces).insert(
(active.bank, active.pattern, source_idx),
std::mem::take(&mut trace),
);
let event_time = Some(Self::beat_to_engine_time(
target_beat,
beat,
engine_time,
tempo,
));
for cmd in cmds {
self.event_count += 1;
self.pending_commands.push(PendingCommand {
cmd: TimestampedCommand { cmd, time: event_time },
target_beat,
bank: active.bank,
pattern: active.pattern,
});
}
}
}
}
}
}
// Mark step as scheduled
self.scheduled_steps.insert(
sched_key,
ScheduledStep {
target_beat,
tempo_at_schedule: tempo,
},
);
// Advance for next iteration
let next = lookahead_step + 1;
if next >= pattern.length {
lookahead_iter += 1;
}
lookahead_step = next % pattern.length;
}
}
}
// === EMIT READY COMMANDS ===
// Move commands whose target_beat has passed from pending to output
let (ready, still_pending): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending_commands)
.into_iter()
.partition(|p| p.target_beat <= beat);
self.pending_commands = still_pending;
for pending in ready {
self.buf_audio_commands.push(pending.cmd);
}
// Cleanup stale scheduled_steps (more than 1 beat in the past)
self.scheduled_steps
.retain(|_, s| s.target_beat > beat - 1.0);
result
}
@@ -1905,4 +2196,178 @@ mod tests {
let output = state.tick(tick_at(1.0, true));
assert_eq!(output.new_tempo, Some(140.0));
}
fn tick_with_lookahead(beat: f64, lookahead_secs: f64) -> TickInput {
TickInput {
commands: Vec::new(),
playing: true,
beat,
tempo: 120.0,
quantum: 4.0,
fill: false,
nudge_secs: 0.0,
current_time_us: 0,
engine_time: beat * 0.5, // At 120 BPM, 1 beat = 0.5 seconds
lookahead_secs,
#[cfg(feature = "desktop")]
mouse_x: 0.5,
#[cfg(feature = "desktop")]
mouse_y: 0.5,
#[cfg(feature = "desktop")]
mouse_down: 0.0,
}
}
fn pattern_with_sound(length: usize) -> PatternSnapshot {
PatternSnapshot {
speed: Default::default(),
length,
steps: (0..length)
.map(|_| StepSnapshot {
active: true,
script: "sine sound 500 freq .".into(),
source: None,
})
.collect(),
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
}
}
#[test]
fn test_lookahead_pre_evaluates_future_steps() {
let mut state = make_state();
state.tick(tick_with(
vec![SeqCommand::PatternUpdate {
bank: 0,
pattern: 0,
data: pattern_with_sound(4),
}],
0.0,
));
state.tick(tick_with(
vec![SeqCommand::PatternStart {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
}],
0.5,
));
// With 100ms lookahead at 120 BPM = 0.2 beats lookahead
// At beat 0.75, future_beat = 0.95
// beat_int = 3, future_beat_int = 3
// next_beat_int = 4 > future_beat_int, so no lookahead yet
let output = state.tick(tick_with_lookahead(0.75, 0.1));
// Step fired (step 1), commands emitted immediately
assert!(output.shared_state.active_patterns.iter().any(|p| p.step_index == 2));
// With 500ms lookahead = 1 beat lookahead
// At beat 1.0, future_beat = 2.0
// beat_int = 4, future_beat_int = 8
// Should pre-evaluate steps at beat_ints 5, 6, 7, 8
let _output = state.tick(tick_with_lookahead(1.0, 0.5));
// Check that scheduled_steps contains the pre-evaluated steps
// At beat 1.0, step_index is 3 (step 2 just fired)
// Lookahead will schedule steps: 3@5, 0@6, 1@7, 2@8
assert!(state.scheduled_steps.contains_key(&(0, 0, 3, 5)));
assert!(state.scheduled_steps.contains_key(&(0, 0, 0, 6)));
assert!(state.scheduled_steps.contains_key(&(0, 0, 1, 7)));
assert!(state.scheduled_steps.contains_key(&(0, 0, 2, 8)));
// Pending commands should exist for future steps
assert!(!state.pending_commands.is_empty());
}
#[test]
fn test_lookahead_commands_emit_at_correct_time() {
let mut state = make_state();
state.tick(tick_with(
vec![SeqCommand::PatternUpdate {
bank: 0,
pattern: 0,
data: pattern_with_sound(4),
}],
0.0,
));
state.tick(tick_with(
vec![SeqCommand::PatternStart {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
}],
0.5,
));
// Pre-evaluate with 1 beat lookahead
state.tick(tick_with_lookahead(0.75, 0.5));
// Commands for step 2 (at beat 1.0) should be in pending_commands
let pending_for_step2: Vec<_> = state
.pending_commands
.iter()
.filter(|p| (p.target_beat - 1.0).abs() < 0.01)
.collect();
assert!(!pending_for_step2.is_empty());
// Advance to beat 1.0 - pending commands should be emitted
let output = state.tick(tick_with_lookahead(1.0, 0.5));
// The commands should have been moved to buf_audio_commands
assert!(!output.audio_commands.is_empty());
}
#[test]
fn test_lookahead_tempo_change_reschedules() {
let mut state = make_state();
state.tick(tick_with(
vec![SeqCommand::PatternUpdate {
bank: 0,
pattern: 0,
data: pattern_with_sound(4),
}],
0.0,
));
state.tick(tick_with(
vec![SeqCommand::PatternStart {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
}],
0.5,
));
// Pre-evaluate with lookahead
state.tick(tick_with_lookahead(0.75, 0.5));
// Record original event times
let original_times: Vec<_> = state
.pending_commands
.iter()
.map(|p| p.cmd.time)
.collect();
assert!(!original_times.is_empty());
// Simulate tempo change by ticking with different tempo
let mut input = tick_with_lookahead(0.8, 0.5);
input.tempo = 140.0; // Changed from 120
state.tick(input);
// Event times should have been rescheduled
// (The exact times depend on the reschedule algorithm)
// At minimum, scheduled_steps should have updated tempo
for step in state.scheduled_steps.values() {
// Tempo should be updated for all scheduled steps
assert!((step.tempo_at_schedule - 140.0).abs() < 0.01);
}
}
}

View File

@@ -1054,6 +1054,22 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
rotation: "0".to_string(),
}));
}
KeyCode::Char('m') => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
ctx.dispatch(AppCommand::StageMute { bank, pattern });
}
KeyCode::Char('x') => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
ctx.dispatch(AppCommand::StageSolo { bank, pattern });
}
KeyCode::Char('M') => {
ctx.dispatch(AppCommand::ClearMutes);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
KeyCode::Char('X') => {
ctx.dispatch(AppCommand::ClearSolos);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
_ => {}
}
InputResult::Continue
@@ -1070,7 +1086,9 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
KeyCode::Up => ctx.dispatch(AppCommand::PatternsCursorUp),
KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown),
KeyCode::Esc => {
if !ctx.app.playback.staged_changes.is_empty() {
if !ctx.app.playback.staged_changes.is_empty()
|| !ctx.app.playback.staged_mute_changes.is_empty()
{
ctx.dispatch(AppCommand::ClearStagedChanges);
} else {
ctx.dispatch(AppCommand::PatternsBack);
@@ -1082,7 +1100,12 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
ctx.dispatch(AppCommand::PatternsTogglePlay);
}
}
KeyCode::Char('c') if !ctrl => ctx.dispatch(AppCommand::CommitStagedChanges),
KeyCode::Char('c') if !ctrl => {
let mute_changed = ctx.app.commit_staged_changes();
if mute_changed {
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
}
KeyCode::Char('q') => {
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit {
selected: false,
@@ -1165,6 +1188,24 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern });
}
}
KeyCode::Char('m') => {
let bank = ctx.app.patterns_nav.bank_cursor;
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::StageMute { bank, pattern });
}
KeyCode::Char('x') => {
let bank = ctx.app.patterns_nav.bank_cursor;
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::StageSolo { bank, pattern });
}
KeyCode::Char('M') => {
ctx.dispatch(AppCommand::ClearMutes);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
KeyCode::Char('X') => {
ctx.dispatch(AppCommand::ClearSolos);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}

View File

@@ -40,6 +40,12 @@ fn convert_event(event: &egui::Event) -> Option<KeyEvent> {
}
None
}
// egui intercepts Ctrl+C/V/X and converts them to these high-level events
// instead of passing through raw Key events (see egui issue #4065).
// Synthesize the equivalent KeyEvent so the application's input handler receives them.
egui::Event::Copy => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
egui::Event::Cut => Some(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
egui::Event::Paste(_) => Some(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)),
_ => None,
}
}

View File

@@ -19,6 +19,7 @@ pub mod editor;
pub mod file_browser;
pub mod live_keys;
pub mod modal;
pub mod mute;
pub mod options;
pub mod panel;
pub mod patterns_nav;
@@ -38,7 +39,8 @@ pub use modal::Modal;
pub use options::{OptionsFocus, OptionsState};
pub use panel::{PanelFocus, PanelState, SidePanel};
pub use patterns_nav::{PatternsColumn, PatternsNav};
pub use playback::{PlaybackState, StagedChange};
pub use mute::MuteState;
pub use playback::{PlaybackState, StagedChange, StagedMuteChange};
pub use project::ProjectState;
pub use sample_browser::SampleBrowserState;
pub use ui::{DictFocus, FlashKind, HelpFocus, UiState};

53
src/state/mute.rs Normal file
View File

@@ -0,0 +1,53 @@
use std::collections::HashSet;
#[derive(Default)]
pub struct MuteState {
pub muted: HashSet<(usize, usize)>,
pub soloed: HashSet<(usize, usize)>,
}
impl MuteState {
pub fn toggle_mute(&mut self, bank: usize, pattern: usize) {
let key = (bank, pattern);
if self.muted.contains(&key) {
self.muted.remove(&key);
} else {
self.muted.insert(key);
}
}
pub fn toggle_solo(&mut self, bank: usize, pattern: usize) {
let key = (bank, pattern);
if self.soloed.contains(&key) {
self.soloed.remove(&key);
} else {
self.soloed.insert(key);
}
}
pub fn clear_mute(&mut self) {
self.muted.clear();
}
pub fn clear_solo(&mut self) {
self.soloed.clear();
}
pub fn is_muted(&self, bank: usize, pattern: usize) -> bool {
self.muted.contains(&(bank, pattern))
}
pub fn is_soloed(&self, bank: usize, pattern: usize) -> bool {
self.soloed.contains(&(bank, pattern))
}
pub fn is_effectively_muted(&self, bank: usize, pattern: usize) -> bool {
if self.muted.contains(&(bank, pattern)) {
return true;
}
if !self.soloed.is_empty() && !self.soloed.contains(&(bank, pattern)) {
return true;
}
false
}
}

View File

@@ -1,5 +1,6 @@
use crate::engine::PatternChange;
use crate::model::{LaunchQuantization, SyncMode};
use std::collections::HashSet;
#[derive(Clone)]
pub struct StagedChange {
@@ -8,10 +9,17 @@ pub struct StagedChange {
pub sync_mode: SyncMode,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum StagedMuteChange {
ToggleMute { bank: usize, pattern: usize },
ToggleSolo { bank: usize, pattern: usize },
}
pub struct PlaybackState {
pub playing: bool,
pub staged_changes: Vec<StagedChange>,
pub queued_changes: Vec<StagedChange>,
pub staged_mute_changes: HashSet<StagedMuteChange>,
}
impl Default for PlaybackState {
@@ -20,6 +28,7 @@ impl Default for PlaybackState {
playing: true,
staged_changes: Vec::new(),
queued_changes: Vec::new(),
staged_mute_changes: HashSet::new(),
}
}
}
@@ -33,4 +42,38 @@ impl PlaybackState {
self.staged_changes.clear();
self.queued_changes.clear();
}
pub fn stage_mute(&mut self, bank: usize, pattern: usize) {
let change = StagedMuteChange::ToggleMute { bank, pattern };
if self.staged_mute_changes.contains(&change) {
self.staged_mute_changes.remove(&change);
} else {
self.staged_mute_changes.insert(change);
}
}
pub fn stage_solo(&mut self, bank: usize, pattern: usize) {
let change = StagedMuteChange::ToggleSolo { bank, pattern };
if self.staged_mute_changes.contains(&change) {
self.staged_mute_changes.remove(&change);
} else {
self.staged_mute_changes.insert(change);
}
}
pub fn clear_staged_mutes(&mut self) {
self.staged_mute_changes.retain(|c| !matches!(c, StagedMuteChange::ToggleMute { .. }));
}
pub fn clear_staged_solos(&mut self) {
self.staged_mute_changes.retain(|c| !matches!(c, StagedMuteChange::ToggleSolo { .. }));
}
pub fn has_staged_mute(&self, bank: usize, pattern: usize) -> bool {
self.staged_mute_changes.contains(&StagedMuteChange::ToggleMute { bank, pattern })
}
pub fn has_staged_solo(&self, bank: usize, pattern: usize) -> bool {
self.staged_mute_changes.contains(&StagedMuteChange::ToggleSolo { bank, pattern })
}
}

View File

@@ -323,6 +323,8 @@ fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
}
fn render_active_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
use crate::widgets::MuteStatus;
let theme = theme::get();
let block = Block::default()
.borders(Borders::ALL)
@@ -336,11 +338,27 @@ fn render_active_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnap
.map(|p| (p.bank, p.pattern, p.iter))
.collect();
let mute_status: Vec<MuteStatus> = snapshot
.active_patterns
.iter()
.map(|p| {
if app.mute.is_soloed(p.bank, p.pattern) {
MuteStatus::Soloed
} else if app.mute.is_muted(p.bank, p.pattern) {
MuteStatus::Muted
} else if app.mute.is_effectively_muted(p.bank, p.pattern) {
MuteStatus::EffectivelyMuted
} else {
MuteStatus::Normal
}
})
.collect();
let step_info = snapshot
.get_step(app.editor_ctx.bank, app.editor_ctx.pattern)
.map(|step| (step, app.current_edit_pattern().length));
let mut widget = ActivePatterns::new(&patterns);
let mut widget = ActivePatterns::new(&patterns).with_mute_status(&mute_status);
if let Some((step, total)) = step_info {
widget = widget.with_step(step, total);
}

View File

@@ -91,13 +91,41 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
let is_playing = banks_with_playback.contains(&idx);
let is_staged = banks_with_staged.contains(&idx);
let (bg, fg, prefix) = match (is_cursor, is_playing, is_staged) {
(true, _, _) => (theme.selection.cursor, theme.selection.cursor_fg, ""),
(false, true, _) => (theme.list.playing_bg, theme.list.playing_fg, "> "),
(false, false, true) => (theme.list.staged_play_bg, theme.list.staged_play_fg, "+ "),
(false, false, false) if is_selected => (theme.list.hover_bg, theme.list.hover_fg, ""),
(false, false, false) if is_edit => (theme.list.edit_bg, theme.list.edit_fg, ""),
(false, false, false) => (theme.ui.bg, theme.ui.text_muted, ""),
// Check if any pattern in this bank is muted/soloed (applied)
let has_muted = (0..MAX_PATTERNS).any(|p| app.mute.is_muted(idx, p));
let has_soloed = (0..MAX_PATTERNS).any(|p| app.mute.is_soloed(idx, p));
// Check if any pattern in this bank has staged mute/solo
let has_staged_mute = (0..MAX_PATTERNS).any(|p| app.playback.has_staged_mute(idx, p));
let has_staged_solo = (0..MAX_PATTERNS).any(|p| app.playback.has_staged_solo(idx, p));
let has_staged_mute_solo = has_staged_mute || has_staged_solo;
let (bg, fg, prefix) = if is_cursor {
(theme.selection.cursor, theme.selection.cursor_fg, "")
} else if is_playing {
if has_staged_mute_solo {
(theme.list.staged_play_bg, theme.list.staged_play_fg, ">*")
} else if has_soloed {
(theme.list.soloed_bg, theme.list.soloed_fg, ">S")
} else if has_muted {
(theme.list.muted_bg, theme.list.muted_fg, ">M")
} else {
(theme.list.playing_bg, theme.list.playing_fg, "> ")
}
} else if is_staged {
(theme.list.staged_play_bg, theme.list.staged_play_fg, "+ ")
} else if has_staged_mute_solo {
(theme.list.staged_play_bg, theme.list.staged_play_fg, " *")
} else if has_soloed && is_selected {
(theme.list.soloed_bg, theme.list.soloed_fg, " S")
} else if has_muted && is_selected {
(theme.list.muted_bg, theme.list.muted_fg, " M")
} else if is_selected {
(theme.list.hover_bg, theme.list.hover_fg, "")
} else if is_edit {
(theme.list.edit_bg, theme.list.edit_fg, "")
} else {
(theme.ui.bg, theme.ui.text_muted, "")
};
let name = app.project_state.project.banks[idx]
@@ -250,14 +278,74 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
let is_staged_play = staged_to_play.contains(&idx);
let is_staged_stop = staged_to_stop.contains(&idx);
let (bg, fg, prefix) = match (is_cursor, is_playing, is_staged_play, is_staged_stop) {
(true, _, _, _) => (theme.selection.cursor, theme.selection.cursor_fg, ""),
(false, true, _, true) => (theme.list.staged_stop_bg, theme.list.staged_stop_fg, "- "),
(false, true, _, false) => (theme.list.playing_bg, theme.list.playing_fg, "> "),
(false, false, true, _) => (theme.list.staged_play_bg, theme.list.staged_play_fg, "+ "),
(false, false, false, _) if is_selected => (theme.list.hover_bg, theme.list.hover_fg, ""),
(false, false, false, _) if is_edit => (theme.list.edit_bg, theme.list.edit_fg, ""),
(false, false, false, _) => (theme.ui.bg, theme.ui.text_muted, ""),
// Current applied mute/solo state
let is_muted = app.mute.is_muted(bank, idx);
let is_soloed = app.mute.is_soloed(bank, idx);
// Staged mute/solo (will toggle on commit)
let has_staged_mute = app.playback.has_staged_mute(bank, idx);
let has_staged_solo = app.playback.has_staged_solo(bank, idx);
// Preview state (what it will be after commit)
let preview_muted = is_muted ^ has_staged_mute;
let preview_soloed = is_soloed ^ has_staged_solo;
let is_effectively_muted = app.mute.is_effectively_muted(bank, idx);
let (bg, fg, prefix) = if is_cursor {
(theme.selection.cursor, theme.selection.cursor_fg, "")
} else if is_playing {
// Playing patterns
if is_staged_stop {
(theme.list.staged_stop_bg, theme.list.staged_stop_fg, "- ")
} else if has_staged_solo {
// Staged solo toggle on playing pattern
if preview_soloed {
(theme.list.soloed_bg, theme.list.soloed_fg, "+S")
} else {
(theme.list.playing_bg, theme.list.playing_fg, "-S")
}
} else if has_staged_mute {
// Staged mute toggle on playing pattern
if preview_muted {
(theme.list.muted_bg, theme.list.muted_fg, "+M")
} else {
(theme.list.playing_bg, theme.list.playing_fg, "-M")
}
} else if is_soloed {
(theme.list.soloed_bg, theme.list.soloed_fg, ">S")
} else if is_muted {
(theme.list.muted_bg, theme.list.muted_fg, ">M")
} else if is_effectively_muted {
(theme.list.muted_bg, theme.list.muted_fg, "> ")
} else {
(theme.list.playing_bg, theme.list.playing_fg, "> ")
}
} else if is_staged_play {
(theme.list.staged_play_bg, theme.list.staged_play_fg, "+ ")
} else if has_staged_solo {
// Staged solo on non-playing pattern
if preview_soloed {
(theme.list.soloed_bg, theme.list.soloed_fg, "+S")
} else {
(theme.ui.bg, theme.ui.text_muted, "-S")
}
} else if has_staged_mute {
// Staged mute on non-playing pattern
if preview_muted {
(theme.list.muted_bg, theme.list.muted_fg, "+M")
} else {
(theme.ui.bg, theme.ui.text_muted, "-M")
}
} else if is_soloed {
(theme.list.soloed_bg, theme.list.soloed_fg, " S")
} else if is_muted {
(theme.list.muted_bg, theme.list.muted_fg, " M")
} else if is_selected {
(theme.list.hover_bg, theme.list.hover_fg, "")
} else if is_edit {
(theme.list.edit_bg, theme.list.edit_fg, "")
} else {
(theme.ui.bg, theme.ui.text_muted, "")
};
let pattern = &app.project_state.project.banks[bank].patterns[idx];

View File

@@ -1,4 +1,4 @@
pub use cagire_ratatui::{
ActivePatterns, ConfirmModal, FileBrowserModal, ModalFrame, NavMinimap, NavTile, Orientation,
SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter,
ActivePatterns, ConfirmModal, FileBrowserModal, ModalFrame, MuteStatus, NavMinimap, NavTile,
Orientation, SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter,
};