Feat: better user feedback on patterns page
All checks were successful
Deploy Website / deploy (push) Has been skipped
All checks were successful
Deploy Website / deploy (push) Has been skipped
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user