Feat: better user feedback on patterns page
All checks were successful
Deploy Website / deploy (push) Has been skipped

This commit is contained in:
2026-03-04 23:41:11 +01:00
parent 4e1c04f9c7
commit 35370a6f2c
11 changed files with 142 additions and 49 deletions

View File

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

View File

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

View File

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

View File

@@ -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"),

View File

@@ -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<String> {
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<String> = [
(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;

View File

@@ -86,6 +86,7 @@ pub struct UiState {
pub demo_index: usize,
pub nav_indicator_until: Option<Instant>,
pub nav_fx: RefCell<Option<Effect>>,
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,
}
}

View File

@@ -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"));

View File

@@ -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<Span<'_>>, filled_cols: usize, unfilled_bg: Color) -> Vec<Span<'_>> {
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));

View File

@@ -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"),
],