From 35370a6f2c4426bd9af0cbfa77046af4c438ffb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Wed, 4 Mar 2026 23:41:11 +0100 Subject: [PATCH] Feat: better user feedback on patterns page --- docs/getting-started/banks_patterns.md | 16 ++++---- docs/getting-started/staging.md | 40 +++++++++--------- src/app/dispatch.rs | 2 +- src/app/staging.rs | 12 +++--- src/main.rs | 8 +++- src/model/onboarding.rs | 10 ++--- src/state/playback.rs | 28 +++++++++++++ src/state/ui.rs | 2 + src/views/keybindings.rs | 14 +++---- src/views/patterns_view.rs | 57 +++++++++++++++++++++++++- src/views/render.rs | 2 +- 11 files changed, 142 insertions(+), 49 deletions(-) diff --git a/docs/getting-started/banks_patterns.md b/docs/getting-started/banks_patterns.md index 156358c..a479874 100644 --- a/docs/getting-started/banks_patterns.md +++ b/docs/getting-started/banks_patterns.md @@ -31,7 +31,7 @@ Each pattern is an independent sequence of steps with its own properties: | Sync Mode | Reset or Phase-Lock on re-trigger | `Reset` | | Follow Up | What happens when the pattern finishes an iteration | `Loop` | -Press `e` in the patterns view to edit these settings. After editing properties, you will have to hit the `c` key to _commit_ these changes. More about that later! +Press `e` in the patterns view to edit these settings. After editing properties, you will have to hit the `c` key to _launch_ these changes. More about that later! ### Follow Up @@ -46,12 +46,12 @@ The follow-up action determines what happens when a pattern reaches the end of i Access the patterns view with `F2` (or `Ctrl+Up` from the sequencer). The view shows all banks and patterns in a grid. Indicators show pattern state: - `>` Currently playing -- `+` Staged to play -- `-` Staged to stop +- `+` Armed to play +- `-` Armed to stop - `M` Muted - `S` Soloed -It is quite essential for you to understand the stage / commit system in order to use patterns. Please read the next section carefully! +It is quite essential for you to understand the arm / launch system in order to use patterns. Please read the next section carefully! ### Keybindings @@ -59,13 +59,13 @@ It is quite essential for you to understand the stage / commit system in order t |-----|--------| | `Arrows` | Navigate banks and patterns | | `Enter` | Select and return to sequencer | -| `p` | Stage pattern to play/stop | -| `c` | Commit staged changes | -| `m` / `x` | Stage mute / solo toggle | +| `p` | Arm pattern to play/stop | +| `c` | Launch armed changes | +| `m` / `x` | Arm mute / solo toggle | | `e` | Edit pattern properties | | `r` | Rename bank or pattern | | `Ctrl+c` / `Ctrl+v` | Copy / Paste | | `Delete` | Reset to empty pattern | -| `Esc` | Cancel staged changes | +| `Esc` | Cancel armed changes | diff --git a/docs/getting-started/staging.md b/docs/getting-started/staging.md index 400f451..d9bc78f 100644 --- a/docs/getting-started/staging.md +++ b/docs/getting-started/staging.md @@ -1,35 +1,35 @@ -# Stage / Commit +# Arm / Launch -In Cagire, changes to playback happen in two steps. First you **stage**: you mark what you want to happen. Then you **commit**: you apply all staged changes at once. Nothing changes until you commit. It is simpler than it sounds. +In Cagire, changes to playback happen in two steps. First you **arm**: you mark what you want to happen. Then you **launch**: you apply all armed changes at once. Nothing changes until you launch. It is simpler than it sounds. -Say you want patterns `04` and `05` to start playing together. You stage both (`p` on each), then commit (`c`). Both start at the same time. Want to stop them later? Stage them again, commit again. That's it. +Say you want patterns `04` and `05` to start playing together. You arm both (`p` on each), then launch (`c`). Both start at the same time. Want to stop them later? Arm them again, launch again. That's it. This two-step process exists for good reasons: -- **Multiple changes at once**: queue several patterns to start/stop, commit them together. +- **Multiple changes at once**: queue several patterns to start/stop, launch them together. - **Clean timing**: all changes land on beat or bar boundaries, never mid-step. - **Safe preparation**: set up the next section while the current one keeps playing. -## Push changes, then apply +## Arm changes, then launch -Staging is an essential feature to understand to be effective when doing live performances: +Arming is an essential feature to understand to be effective when doing live performances: 1. Open the **Patterns** view (`F2` or `Ctrl+Up` from sequencer) 2. Navigate to a pattern you wish to change/play -3. Press `p` to stage it. The pending change is going to be displayed: - - `+` (staged to play) - - `-` (staged to stop) - - `m` (staged to mute) - - `s` (staged to solo) +3. Press `p` to arm it. The pending change is going to be displayed: + - `+` (armed to play) + - `-` (armed to stop) + - `m` (armed to mute) + - `s` (armed to solo) - etc. 4. Repeat for other patterns you want to change -5. Press `c` to commit all changes +5. Press `c` to launch all changes 6. Or press `Esc` to cancel -You can also stage mute/solo changes: +You can also arm mute/solo changes: -- Press `m` to stage a mute toggle -- Press `x` to stage a solo toggle +- Press `m` to arm a mute toggle +- Press `x` to arm a solo toggle - Press `Shift+m` to clear all mutes - Press `Shift+x` to clear all solos @@ -41,16 +41,18 @@ It might wait for the next beat/bar boundary. | Indicator | Meaning | |-----------|---------| | `>` | Currently playing | -| `+` | Staged to play | -| `-` | Staged to stop | +| `+` | Armed to play | +| `-` | Armed to stop | | `M` | Muted | | `S` | Soloed | -A pattern can show combined indicators, e.g. `>` (playing) and `-` (staged to stop), or `>M` (playing and muted). +A pattern can show combined indicators, e.g. `>` (playing) and `-` (armed to stop), or `>M` (playing and muted). + +Armed patterns blink to make pending changes impossible to miss. ## Quantization -Committed changes don't execute immediately. They wait for a quantization boundary: +Launched changes don't execute immediately. They wait for a quantization boundary: | Setting | Behavior | |---------|----------| diff --git a/src/app/dispatch.rs b/src/app/dispatch.rs index ff67451..d339d83 100644 --- a/src/app/dispatch.rs +++ b/src/app/dispatch.rs @@ -215,7 +215,7 @@ impl App { }, ); self.ui - .set_status(format!("{} props staged", bp_label(bank, pattern))); + .set_status(format!("{} props armed", bp_label(bank, pattern))); } // Page navigation diff --git a/src/app/staging.rs b/src/app/staging.rs index fc407d6..4bdb39f 100644 --- a/src/app/staging.rs +++ b/src/app/staging.rs @@ -24,7 +24,7 @@ impl App { if let Some(idx) = existing { self.playback.staged_changes.remove(idx); self.ui - .set_status(format!("{} unstaged", bp_label(bank, pattern))); + .set_status(format!("{} disarmed", bp_label(bank, pattern))); } else if is_playing { self.playback.staged_changes.push(StagedChange { change: PatternChange::Stop { bank, pattern }, @@ -32,7 +32,7 @@ impl App { sync_mode: pattern_data.sync_mode, }); self.ui - .set_status(format!("{} staged to stop", bp_label(bank, pattern))); + .set_status(format!("{} armed to stop", bp_label(bank, pattern))); } else { self.playback.staged_changes.push(StagedChange { change: PatternChange::Start { bank, pattern }, @@ -40,7 +40,7 @@ impl App { sync_mode: pattern_data.sync_mode, }); self.ui - .set_status(format!("{} staged to play", bp_label(bank, pattern))); + .set_status(format!("{} armed to play", bp_label(bank, pattern))); } } @@ -52,7 +52,7 @@ impl App { let prop_count = self.playback.staged_prop_changes.len(); if pattern_count == 0 && mute_count == 0 && prop_count == 0 { - self.ui.set_status("No changes to commit".to_string()); + self.ui.set_status("No changes to launch".to_string()); return false; } @@ -90,7 +90,7 @@ impl App { } let total = pattern_count + mute_count + prop_count; - let status = format!("Committed {total} changes"); + let status = format!("Launched {total} changes"); self.ui.set_status(status); mute_changed @@ -110,7 +110,7 @@ impl App { self.playback.staged_prop_changes.clear(); let total = pattern_count + mute_count + prop_count; - let status = format!("Cleared {total} staged changes"); + let status = format!("Cleared {total} armed changes"); self.ui.set_status(status); } } diff --git a/src/main.rs b/src/main.rs index 5d8763b..5e55dcf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -355,10 +355,16 @@ fn main() -> io::Result<()> { let elapsed = last_frame.elapsed(); last_frame = Instant::now(); + let has_armed = app.playback.has_armed(); + if has_armed { + let rate = std::f32::consts::TAU; // 1 Hz full cycle + app.ui.pulse_phase = (app.ui.pulse_phase + elapsed.as_secs_f32() * rate) % std::f32::consts::TAU; + } let effects_active = app.ui.effects.borrow().is_running() || app.ui.modal_fx.borrow().is_some() || app.ui.title_fx.borrow().is_some() - || app.ui.nav_fx.borrow().is_some(); + || app.ui.nav_fx.borrow().is_some() + || has_armed; let cursor_pulse = app.page == page::Page::Main && !app.ui.performance_mode && !app.playback.playing; let audio_cooldown = !app.playback.playing && last_stop_time.elapsed() < Duration::from_secs(1); if app.playback.playing || had_event || app.ui.show_title || effects_active || app.ui.show_minimap() || cursor_pulse || audio_cooldown { diff --git a/src/model/onboarding.rs b/src/model/onboarding.rs index 5aebee0..780e794 100644 --- a/src/model/onboarding.rs +++ b/src/model/onboarding.rs @@ -30,12 +30,12 @@ pub fn for_page(page: Page) -> &'static [(&'static str, &'static [(&'static str, ], Page::Patterns => &[ ( - "Organize your project into banks and patterns. The left column lists 32 banks, the right shows patterns in the selected bank. The bottom strip previews steps and pattern properties. Stage patterns to play or stop, then commit to apply all changes at once.", + "Organize your project into banks and patterns. The left column lists 32 banks, the right shows patterns in the selected bank. The bottom strip previews steps and pattern properties. Arm patterns to play or stop, then launch to apply all changes at once.", &[ ("Arrows", "navigate"), ("Enter", "open in sequencer"), - ("Space", "stage play/stop"), - ("c", "commit changes"), + ("p", "arm play/stop"), + ("c", "launch changes"), ("r", "rename"), ("e", "properties"), ("?", "all keys"), @@ -44,8 +44,8 @@ pub fn for_page(page: Page) -> &'static [(&'static str, &'static [(&'static str, ( "Mute and solo patterns to control the mix. Use euclidean distribution to generate rhythmic patterns from a single step. Select multiple patterns with Shift for bulk operations.", &[ - ("m", "stage mute"), - ("s", "stage solo"), + ("m", "arm mute"), + ("s", "arm solo"), ("E", "euclidean"), ("Shift+↑↓", "select range"), ("y", "copy"), diff --git a/src/state/playback.rs b/src/state/playback.rs index b7c67fc..0d99f36 100644 --- a/src/state/playback.rs +++ b/src/state/playback.rs @@ -130,6 +130,34 @@ impl PlaybackState { self.soloed.contains(&(bank, pattern)) } + pub fn has_armed(&self) -> bool { + !self.staged_changes.is_empty() + || !self.staged_mute_changes.is_empty() + || !self.staged_prop_changes.is_empty() + } + + pub fn armed_summary(&self) -> Option { + let play = self.staged_changes.iter().filter(|c| matches!(c.change, PatternChange::Start { .. })).count(); + let stop = self.staged_changes.iter().filter(|c| matches!(c.change, PatternChange::Stop { .. })).count(); + let mute = self.staged_mute_changes.iter().filter(|c| matches!(c, StagedMuteChange::ToggleMute { .. })).count(); + let solo = self.staged_mute_changes.iter().filter(|c| matches!(c, StagedMuteChange::ToggleSolo { .. })).count(); + let props = self.staged_prop_changes.len(); + + let parts: Vec = [ + (play, "play"), + (stop, "stop"), + (mute, "mute"), + (solo, "solo"), + (props, "props"), + ] + .into_iter() + .filter(|(n, _)| *n > 0) + .map(|(n, label)| format!("{n} {label}")) + .collect(); + + if parts.is_empty() { None } else { Some(parts.join(", ")) } + } + pub fn is_effectively_muted(&self, bank: usize, pattern: usize) -> bool { if self.muted.contains(&(bank, pattern)) { return true; diff --git a/src/state/ui.rs b/src/state/ui.rs index 1241815..1fe7de9 100644 --- a/src/state/ui.rs +++ b/src/state/ui.rs @@ -86,6 +86,7 @@ pub struct UiState { pub demo_index: usize, pub nav_indicator_until: Option, pub nav_fx: RefCell>, + pub pulse_phase: f32, pub last_click: Option<(Instant, u16, u16)>, } @@ -142,6 +143,7 @@ impl Default for UiState { demo_index: 0, nav_indicator_until: None, nav_fx: RefCell::new(None), + pulse_phase: 0.0, last_click: None, } } diff --git a/src/views/keybindings.rs b/src/views/keybindings.rs index 19515bd..42b3418 100644 --- a/src/views/keybindings.rs +++ b/src/views/keybindings.rs @@ -49,8 +49,8 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati bindings.push(("Ctrl+R", "Run", "Run step script immediately")); bindings.push((":", "Jump", "Jump to step number")); bindings.push(("e", "Euclidean", "Distribute linked steps using Euclidean rhythm")); - bindings.push(("m", "Mute", "Stage mute for current pattern")); - bindings.push(("x", "Solo", "Stage solo for current pattern")); + bindings.push(("m", "Mute", "Arm mute for current pattern")); + bindings.push(("x", "Solo", "Arm solo for current pattern")); bindings.push(("M", "Clear mutes", "Clear all mutes")); bindings.push(("X", "Clear solos", "Clear all solos")); bindings.push(("d", "Eval prelude", "Re-evaluate prelude without editing")); @@ -65,14 +65,14 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati if !plugin_mode { bindings.push(("Space", "Play", "Toggle pattern playback")); } - bindings.push(("Esc", "Back", "Clear staged or go back")); - bindings.push(("c", "Commit", "Commit staged changes")); - bindings.push(("p", "Stage play", "Stage pattern play toggle")); + bindings.push(("Esc", "Back", "Clear armed or go back")); + bindings.push(("c", "Launch", "Launch armed changes")); + bindings.push(("p", "Arm play", "Arm pattern play toggle")); bindings.push(("r", "Rename", "Rename bank/pattern")); bindings.push(("d", "Describe", "Add description to pattern")); bindings.push(("e", "Properties", "Edit pattern properties")); - bindings.push(("m", "Mute", "Stage mute for pattern")); - bindings.push(("x", "Solo", "Stage solo for pattern")); + bindings.push(("m", "Mute", "Arm mute for pattern")); + bindings.push(("x", "Solo", "Arm solo for pattern")); bindings.push(("M", "Clear mutes", "Clear all mutes")); bindings.push(("X", "Clear solos", "Clear all solos")); bindings.push(("g", "Share", "Export bank or pattern to clipboard")); diff --git a/src/views/patterns_view.rs b/src/views/patterns_view.rs index a0df9be..45a1566 100644 --- a/src/views/patterns_view.rs +++ b/src/views/patterns_view.rs @@ -13,6 +13,20 @@ use crate::widgets::{render_scroll_indicators, IndicatorAlign}; const MIN_ROW_HEIGHT: u16 = 1; +fn pulse_value(phase: f32) -> f32 { + phase.sin() * 0.5 + 0.5 +} + +fn pulse_color(from: Color, to: Color, t: f32) -> Color { + match (from, to) { + (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => { + let l = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t) as u8; + Color::Rgb(l(r1, r2), l(g1, g2), l(b1, b2)) + } + _ => from, + } +} + /// Replaces the background color of spans beyond `filled_cols` with `unfilled_bg`. fn apply_progress_bg(spans: Vec>, filled_cols: usize, unfilled_bg: Color) -> Vec> { let mut result = Vec::with_capacity(spans.len() + 1); @@ -54,7 +68,32 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)]).areas(bottom_area); render_banks(frame, app, snapshot, banks_area); - render_patterns(frame, app, snapshot, patterns_area); + + let armed_summary = app.playback.armed_summary(); + let (patterns_main, launch_bar_area) = if armed_summary.is_some() { + let [main, bar] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(patterns_area); + (main, Some(bar)) + } else { + (patterns_area, None) + }; + + render_patterns(frame, app, snapshot, patterns_main); + + if let (Some(bar_area), Some(summary)) = (launch_bar_area, armed_summary) { + let pulse = pulse_value(app.ui.pulse_phase); + let pulsed_fg = pulse_color(theme.list.staged_play_fg, theme.list.staged_play_bg, pulse * 0.6); + let text = format!("\u{25b6} {summary} \u{2014} c to launch"); + let bar = Paragraph::new(text) + .alignment(Alignment::Center) + .style( + Style::new() + .fg(pulsed_fg) + .bg(theme.list.staged_play_bg) + .add_modifier(Modifier::BOLD), + ); + frame.render_widget(bar, bar_area); + } let bank = app.patterns_nav.bank_cursor; let pattern_idx = app.patterns_nav.pattern_cursor; @@ -82,6 +121,7 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { let theme = theme::get(); + let pulse = pulse_value(app.ui.pulse_phase); let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Banks); let border_color = if is_focused { theme.ui.header } else { theme.ui.border }; @@ -215,6 +255,12 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area } else { style }; + let style = if (is_staged || has_staged_mute_solo) && !is_cursor && !is_in_range { + let pulsed = pulse_color(fg, bg, pulse * 0.6); + style.fg(pulsed) + } else { + style + }; let bg_block = Block::default().style(Style::new().bg(bg)); frame.render_widget(bg_block, row_area); @@ -247,6 +293,7 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { use crate::model::PatternSpeed; + let pulse = pulse_value(app.ui.pulse_phase); let theme = theme::get(); let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Patterns); @@ -454,6 +501,14 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a }; let dim_style = base_style.remove_modifier(Modifier::BOLD); + let is_armed = is_staged_play || is_staged_stop || has_staged_mute || has_staged_solo || has_staged_props; + let (name_style, dim_style) = if is_armed && !is_cursor && !is_in_range { + let pulsed = pulse_color(fg, bg, pulse * 0.6); + (name_style.fg(pulsed), dim_style.fg(pulsed)) + } else { + (name_style, dim_style) + }; + let mut spans = vec![Span::styled(format!("{}{:02}", prefix, idx + 1), name_style)]; if !name.is_empty() { spans.push(Span::styled(format!(" {name}"), name_style)); diff --git a/src/views/render.rs b/src/views/render.rs index 26bd38d..b9a3c09 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -552,7 +552,7 @@ fn render_footer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, are Page::Patterns => vec![ ("Enter", "Select"), ("Space", "Play"), - ("c", "Commit"), + ("c", "Launch"), ("r", "Rename"), ("?", "Keys"), ],