From 39ca7de1699cbf69f1a01b8885dd73dd3f90e768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Mon, 2 Feb 2026 16:27:11 +0100 Subject: [PATCH] Pattern mute and so on --- CHANGELOG.md | 5 + crates/ratatui/src/active_patterns.rs | 45 +++++-- crates/ratatui/src/lib.rs | 2 +- crates/ratatui/src/theme/catppuccin_latte.rs | 4 + crates/ratatui/src/theme/catppuccin_mocha.rs | 4 + crates/ratatui/src/theme/dracula.rs | 4 + crates/ratatui/src/theme/fairyfloss.rs | 4 + crates/ratatui/src/theme/gruvbox_dark.rs | 4 + crates/ratatui/src/theme/hot_dog_stand.rs | 4 + crates/ratatui/src/theme/kanagawa.rs | 4 + crates/ratatui/src/theme/mod.rs | 4 + crates/ratatui/src/theme/monochrome_black.rs | 4 + crates/ratatui/src/theme/monochrome_white.rs | 4 + crates/ratatui/src/theme/monokai.rs | 4 + crates/ratatui/src/theme/nord.rs | 4 + crates/ratatui/src/theme/pitch_black.rs | 4 + crates/ratatui/src/theme/rose_pine.rs | 4 + crates/ratatui/src/theme/tokyo_night.rs | 4 + crates/ratatui/src/theme/transform.rs | 4 + src/app.rs | 94 ++++++++++++--- src/commands.rs | 7 +- src/engine/sequencer.rs | 72 +++++++++-- src/input.rs | 45 ++++++- src/state/mod.rs | 4 +- src/state/mute.rs | 53 +++++++++ src/state/playback.rs | 43 +++++++ src/views/main_view.rs | 20 +++- src/views/patterns_view.rs | 118 ++++++++++++++++--- src/widgets/mod.rs | 4 +- 29 files changed, 518 insertions(+), 58 deletions(-) create mode 100644 src/state/mute.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd2336..7b2a8b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ 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`. + ## [0.0.4] - 2026-02-02 ### Added diff --git a/crates/ratatui/src/active_patterns.rs b/crates/ratatui/src/active_patterns.rs index 33f5b6a..94288b1 100644 --- a/crates/ratatui/src/active_patterns.rs +++ b/crates/ratatui/src/active_patterns.rs @@ -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); } } diff --git a/crates/ratatui/src/lib.rs b/crates/ratatui/src/lib.rs index b72a00a..bdd4460 100644 --- a/crates/ratatui/src/lib.rs +++ b/crates/ratatui/src/lib.rs @@ -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; diff --git a/crates/ratatui/src/theme/catppuccin_latte.rs b/crates/ratatui/src/theme/catppuccin_latte.rs index 3af9f8b..70dd492 100644 --- a/crates/ratatui/src/theme/catppuccin_latte.rs +++ b/crates/ratatui/src/theme/catppuccin_latte.rs @@ -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, diff --git a/crates/ratatui/src/theme/catppuccin_mocha.rs b/crates/ratatui/src/theme/catppuccin_mocha.rs index 349e3f1..67f8ded 100644 --- a/crates/ratatui/src/theme/catppuccin_mocha.rs +++ b/crates/ratatui/src/theme/catppuccin_mocha.rs @@ -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, diff --git a/crates/ratatui/src/theme/dracula.rs b/crates/ratatui/src/theme/dracula.rs index 681e58d..a029a9b 100644 --- a/crates/ratatui/src/theme/dracula.rs +++ b/crates/ratatui/src/theme/dracula.rs @@ -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, diff --git a/crates/ratatui/src/theme/fairyfloss.rs b/crates/ratatui/src/theme/fairyfloss.rs index 3201fa6..8f5b04d 100644 --- a/crates/ratatui/src/theme/fairyfloss.rs +++ b/crates/ratatui/src/theme/fairyfloss.rs @@ -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, diff --git a/crates/ratatui/src/theme/gruvbox_dark.rs b/crates/ratatui/src/theme/gruvbox_dark.rs index 4accfc5..ea42740 100644 --- a/crates/ratatui/src/theme/gruvbox_dark.rs +++ b/crates/ratatui/src/theme/gruvbox_dark.rs @@ -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, diff --git a/crates/ratatui/src/theme/hot_dog_stand.rs b/crates/ratatui/src/theme/hot_dog_stand.rs index d4a339f..1cbdfa3 100644 --- a/crates/ratatui/src/theme/hot_dog_stand.rs +++ b/crates/ratatui/src/theme/hot_dog_stand.rs @@ -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, diff --git a/crates/ratatui/src/theme/kanagawa.rs b/crates/ratatui/src/theme/kanagawa.rs index febbfbe..18077a7 100644 --- a/crates/ratatui/src/theme/kanagawa.rs +++ b/crates/ratatui/src/theme/kanagawa.rs @@ -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, diff --git a/crates/ratatui/src/theme/mod.rs b/crates/ratatui/src/theme/mod.rs index 5b58e73..52c77c8 100644 --- a/crates/ratatui/src/theme/mod.rs +++ b/crates/ratatui/src/theme/mod.rs @@ -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)] diff --git a/crates/ratatui/src/theme/monochrome_black.rs b/crates/ratatui/src/theme/monochrome_black.rs index 9930a7a..d6ffd09 100644 --- a/crates/ratatui/src/theme/monochrome_black.rs +++ b/crates/ratatui/src/theme/monochrome_black.rs @@ -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, diff --git a/crates/ratatui/src/theme/monochrome_white.rs b/crates/ratatui/src/theme/monochrome_white.rs index d19ce72..384aecd 100644 --- a/crates/ratatui/src/theme/monochrome_white.rs +++ b/crates/ratatui/src/theme/monochrome_white.rs @@ -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, diff --git a/crates/ratatui/src/theme/monokai.rs b/crates/ratatui/src/theme/monokai.rs index 42705f9..788db3a 100644 --- a/crates/ratatui/src/theme/monokai.rs +++ b/crates/ratatui/src/theme/monokai.rs @@ -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, diff --git a/crates/ratatui/src/theme/nord.rs b/crates/ratatui/src/theme/nord.rs index 2e06828..feb713e 100644 --- a/crates/ratatui/src/theme/nord.rs +++ b/crates/ratatui/src/theme/nord.rs @@ -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, diff --git a/crates/ratatui/src/theme/pitch_black.rs b/crates/ratatui/src/theme/pitch_black.rs index de926c9..7c1f951 100644 --- a/crates/ratatui/src/theme/pitch_black.rs +++ b/crates/ratatui/src/theme/pitch_black.rs @@ -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, diff --git a/crates/ratatui/src/theme/rose_pine.rs b/crates/ratatui/src/theme/rose_pine.rs index 81877bd..30eea13 100644 --- a/crates/ratatui/src/theme/rose_pine.rs +++ b/crates/ratatui/src/theme/rose_pine.rs @@ -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, diff --git a/crates/ratatui/src/theme/tokyo_night.rs b/crates/ratatui/src/theme/tokyo_night.rs index a6b1120..e493c93 100644 --- a/crates/ratatui/src/theme/tokyo_night.rs +++ b/crates/ratatui/src/theme/tokyo_night.rs @@ -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, diff --git a/crates/ratatui/src/theme/transform.rs b/crates/ratatui/src/theme/transform.rs index 1bdba8e..1d92b95 100644 --- a/crates/ratatui/src/theme/transform.rs +++ b/crates/ratatui/src/theme/transform.rs @@ -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), diff --git a/src/app.rs b/src/app.rs index 413a063..ea506a6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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) { + 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) { for (bank, pattern) in self.project_state.take_dirty() { let pat = self.project_state.project.pattern_at(bank, pattern); diff --git a/src/commands.rs b/src/commands.rs index cc8a817..81aba47 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -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, diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 6e0006a..4c8ddb7 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -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, } @@ -551,6 +555,8 @@ pub(crate) struct SequencerState { buf_audio_commands: Vec, cc_access: Option>, active_notes: HashMap<(u8, u8, u8), ActiveNote>, + muted: std::collections::HashSet<(usize, usize)>, + soloed: std::collections::HashSet<(usize, usize)>, } impl SequencerState { @@ -575,9 +581,22 @@ impl SequencerState { buf_audio_commands: Vec::new(), cc_access, active_notes: HashMap::new(), + muted: std::collections::HashSet::new(), + soloed: std::collections::HashSet::new(), } } + 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) { for cmd in commands { match cmd { @@ -619,6 +638,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(); @@ -783,6 +824,9 @@ impl SequencerState { } } + let muted_snapshot = self.muted.clone(); + let soloed_snapshot = self.soloed.clone(); + for (_id, active) in self.audio_state.active_patterns.iter_mut() { let Some(pattern) = self.pattern_cache.get(active.bank, active.pattern) else { continue; @@ -807,6 +851,10 @@ impl SequencerState { .unwrap_or(false); if step.active && has_script { + let key = (active.bank, active.pattern); + let is_muted = muted_snapshot.contains(&key) + || (!soloed_snapshot.is_empty() && !soloed_snapshot.contains(&key)); + let source_idx = pattern.resolve_source(step_idx); let runs = self.runs_counter.get_and_increment( active.bank, @@ -845,18 +893,20 @@ impl SequencerState { std::mem::take(&mut trace), ); - let event_time = if lookahead_secs > 0.0 { - Some(engine_time + lookahead_secs) - } else { - None - }; + if !is_muted { + 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, - }); + for cmd in cmds { + self.event_count += 1; + self.buf_audio_commands.push(TimestampedCommand { + cmd, + time: event_time, + }); + } } } } diff --git a/src/input.rs b/src/input.rs index 51f6058..dcbfcf9 100644 --- a/src/input.rs +++ b/src/input.rs @@ -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 })); } diff --git a/src/state/mod.rs b/src/state/mod.rs index 344ab77..e02053a 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -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}; diff --git a/src/state/mute.rs b/src/state/mute.rs new file mode 100644 index 0000000..9569049 --- /dev/null +++ b/src/state/mute.rs @@ -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 + } +} diff --git a/src/state/playback.rs b/src/state/playback.rs index e84f0af..651b8b9 100644 --- a/src/state/playback.rs +++ b/src/state/playback.rs @@ -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, pub queued_changes: Vec, + pub staged_mute_changes: HashSet, } 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 }) + } } diff --git a/src/views/main_view.rs b/src/views/main_view.rs index b1d22c5..614c131 100644 --- a/src/views/main_view.rs +++ b/src/views/main_view.rs @@ -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 = 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); } diff --git a/src/views/patterns_view.rs b/src/views/patterns_view.rs index 125416f..4c07585 100644 --- a/src/views/patterns_view.rs +++ b/src/views/patterns_view.rs @@ -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]; diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 35e5668..486ad0d 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -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, };