Pattern mute and so on

This commit is contained in:
2026-02-02 16:27:11 +01:00
parent 7c14ce7634
commit 39ca7de169
29 changed files with 518 additions and 58 deletions

View File

@@ -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

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

@@ -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<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)>,
}
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<SeqCommand>) {
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,
});
}
}
}
}

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

@@ -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,
};