So much better

This commit is contained in:
2026-01-26 02:24:04 +01:00
parent bde64e7dc5
commit 1b32a91b0d
16 changed files with 714 additions and 135 deletions

View File

@@ -1,10 +1,10 @@
mod file;
mod project;
pub const MAX_BANKS: usize = 16;
pub const MAX_PATTERNS: usize = 16;
pub const MAX_BANKS: usize = 32;
pub const MAX_PATTERNS: usize = 32;
pub const MAX_STEPS: usize = 128;
pub const DEFAULT_LENGTH: usize = 16;
pub use file::{load, save, FileError};
pub use project::{Bank, Pattern, PatternSpeed, Project, Step};
pub use project::{Bank, LaunchQuantization, Pattern, PatternSpeed, Project, Step, SyncMode};

View File

@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::{DEFAULT_LENGTH, MAX_BANKS, MAX_PATTERNS, MAX_STEPS};
#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq)]
#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
pub enum PatternSpeed {
Eighth, // 1/8x
Quarter, // 1/4x
@@ -79,6 +79,75 @@ impl PatternSpeed {
}
}
#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
pub enum LaunchQuantization {
Immediate,
Beat,
#[default]
Bar,
Bars2,
Bars4,
Bars8,
}
impl LaunchQuantization {
pub fn label(&self) -> &'static str {
match self {
Self::Immediate => "Immediate",
Self::Beat => "Beat",
Self::Bar => "1 Bar",
Self::Bars2 => "2 Bars",
Self::Bars4 => "4 Bars",
Self::Bars8 => "8 Bars",
}
}
pub fn next(&self) -> Self {
match self {
Self::Immediate => Self::Beat,
Self::Beat => Self::Bar,
Self::Bar => Self::Bars2,
Self::Bars2 => Self::Bars4,
Self::Bars4 => Self::Bars8,
Self::Bars8 => Self::Bars8,
}
}
pub fn prev(&self) -> Self {
match self {
Self::Immediate => Self::Immediate,
Self::Beat => Self::Immediate,
Self::Bar => Self::Beat,
Self::Bars2 => Self::Bar,
Self::Bars4 => Self::Bars2,
Self::Bars8 => Self::Bars4,
}
}
}
#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
pub enum SyncMode {
#[default]
Reset,
PhaseLock,
}
impl SyncMode {
pub fn label(&self) -> &'static str {
match self {
Self::Reset => "Reset",
Self::PhaseLock => "Phase-Lock",
}
}
pub fn toggle(&self) -> Self {
match self {
Self::Reset => Self::PhaseLock,
Self::PhaseLock => Self::Reset,
}
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Step {
pub active: bool,
@@ -108,6 +177,10 @@ pub struct Pattern {
pub speed: PatternSpeed,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub quantization: LaunchQuantization,
#[serde(default)]
pub sync_mode: SyncMode,
}
impl Default for Pattern {
@@ -117,6 +190,8 @@ impl Default for Pattern {
length: DEFAULT_LENGTH,
speed: PatternSpeed::default(),
name: None,
quantization: LaunchQuantization::default(),
sync_mode: SyncMode::default(),
}
}
}

View File

@@ -60,6 +60,40 @@ pub struct Editor {
search: SearchState,
}
impl Editor {
pub fn start_selection(&mut self) {
self.text.start_selection();
}
pub fn cancel_selection(&mut self) {
self.text.cancel_selection();
}
pub fn is_selecting(&self) -> bool {
self.text.is_selecting()
}
pub fn copy(&mut self) {
self.text.copy();
}
pub fn cut(&mut self) -> bool {
self.text.cut()
}
pub fn paste(&mut self) -> bool {
self.text.paste()
}
pub fn select_all(&mut self) {
self.text.select_all();
}
pub fn selection_range(&self) -> Option<((usize, usize), (usize, usize))> {
self.text.selection_range()
}
}
impl Default for Editor {
fn default() -> Self {
Self::new()
@@ -300,6 +334,9 @@ impl Editor {
pub fn render(&self, frame: &mut Frame, area: Rect, highlighter: Highlighter) {
let (cursor_row, cursor_col) = self.text.cursor();
let cursor_style = Style::default().bg(Color::White).fg(Color::Black);
let selection_style = Style::default().bg(Color::Rgb(60, 80, 120));
let selection = self.text.selection_range();
let lines: Vec<Line> = self
.text
@@ -309,38 +346,29 @@ impl Editor {
.map(|(row, line)| {
let tokens = highlighter(row, line);
let mut spans: Vec<Span> = Vec::new();
if row == cursor_row {
let mut col = 0;
for (style, text) in tokens {
let text_len = text.chars().count();
if cursor_col >= col && cursor_col < col + text_len {
let before = text.chars().take(cursor_col - col).collect::<String>();
let cursor_char =
text.chars().nth(cursor_col - col).unwrap_or(' ');
let after =
text.chars().skip(cursor_col - col + 1).collect::<String>();
if !before.is_empty() {
spans.push(Span::styled(before, style));
}
spans.push(Span::styled(cursor_char.to_string(), cursor_style));
if !after.is_empty() {
spans.push(Span::styled(after, style));
}
for (base_style, text) in tokens {
for ch in text.chars() {
let is_cursor = row == cursor_row && col == cursor_col;
let is_selected = is_in_selection(row, col, selection);
let style = if is_cursor {
cursor_style
} else if is_selected {
base_style.bg(selection_style.bg.unwrap())
} else {
spans.push(Span::styled(text, style));
base_style
};
spans.push(Span::styled(ch.to_string(), style));
col += 1;
}
col += text_len;
}
if cursor_col >= col {
if row == cursor_row && cursor_col >= col {
spans.push(Span::styled(" ", cursor_style));
}
} else {
for (style, text) in tokens {
spans.push(Span::styled(text, style));
}
}
Line::from(spans)
})
@@ -468,3 +496,23 @@ impl Editor {
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '!' | '@' | '?' | '.' | ':' | '_' | '#')
}
fn is_in_selection(row: usize, col: usize, selection: Option<((usize, usize), (usize, usize))>) -> bool {
let Some(((start_row, start_col), (end_row, end_col))) = selection else {
return false;
};
if row < start_row || row > end_row {
return false;
}
if row == start_row && row == end_row {
col >= start_col && col < end_col
} else if row == start_row {
col >= start_col
} else if row == end_row {
col < end_col
} else {
true
}
}

View File

@@ -16,7 +16,8 @@ use crate::services::pattern_editor;
use crate::settings::Settings;
use crate::state::{
AudioSettings, DictFocus, EditorContext, FlashKind, Focus, LiveKeyState, Metrics, Modal,
OptionsState, PanelState, PatternField, PatternsNav, PlaybackState, ProjectState, UiState,
OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, PlaybackState,
ProjectState, StagedChange, UiState,
};
use crate::views::{dict_view, help_view};
@@ -399,48 +400,76 @@ impl App {
}
}
pub fn toggle_pattern_playback(
pub fn stage_pattern_toggle(
&mut self,
bank: usize,
pattern: usize,
snapshot: &SequencerSnapshot,
) {
let is_playing = snapshot.is_playing(bank, pattern);
let pattern_data = self.project_state.project.pattern_at(bank, pattern);
let pending = self
let existing = self
.playback
.queued_changes
.staged_changes
.iter()
.position(|c| c.pattern_id().bank == bank && c.pattern_id().pattern == pattern);
.position(|c| {
c.change.pattern_id().bank == bank && c.change.pattern_id().pattern == pattern
});
if let Some(idx) = pending {
self.playback.queued_changes.remove(idx);
if let Some(idx) = existing {
self.playback.staged_changes.remove(idx);
self.ui.set_status(format!(
"B{:02}:P{:02} change cancelled",
"B{:02}:P{:02} unstaged",
bank + 1,
pattern + 1
));
} else if is_playing {
self.playback
.queued_changes
.push(PatternChange::Stop { bank, pattern });
self.playback.staged_changes.push(StagedChange {
change: PatternChange::Stop { bank, pattern },
quantization: pattern_data.quantization,
sync_mode: pattern_data.sync_mode,
});
self.ui.set_status(format!(
"B{:02}:P{:02} queued to stop",
"B{:02}:P{:02} staged to stop",
bank + 1,
pattern + 1
));
} else {
self.playback
.queued_changes
.push(PatternChange::Start { bank, pattern });
self.playback.staged_changes.push(StagedChange {
change: PatternChange::Start { bank, pattern },
quantization: pattern_data.quantization,
sync_mode: pattern_data.sync_mode,
});
self.ui.set_status(format!(
"B{:02}:P{:02} queued to play",
"B{:02}:P{:02} staged to play",
bank + 1,
pattern + 1
));
}
}
pub fn commit_staged_changes(&mut self) {
if self.playback.staged_changes.is_empty() {
self.ui.set_status("No changes to commit".to_string());
return;
}
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"));
}
pub fn clear_staged_changes(&mut self) {
if self.playback.staged_changes.is_empty() {
return;
}
let count = self.playback.staged_changes.len();
self.playback.staged_changes.clear();
self.ui.set_status(format!("Cleared {count} staged changes"));
}
pub fn select_edit_pattern(&mut self, pattern: usize) {
self.editor_ctx.pattern = pattern;
self.editor_ctx.step = 0;
@@ -724,6 +753,20 @@ impl App {
};
}
pub fn open_pattern_props_modal(&mut self, bank: usize, pattern: usize) {
let pat = self.project_state.project.pattern_at(bank, pattern);
self.ui.modal = Modal::PatternProps {
bank,
pattern,
field: PatternPropsField::default(),
name: pat.name.clone().unwrap_or_default(),
length: pat.length.to_string(),
speed: pat.speed,
quantization: pat.quantization,
sync_mode: pat.sync_mode,
};
}
pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) {
match cmd {
// Playback
@@ -815,12 +858,15 @@ impl App {
AppCommand::LinkPasteStep => self.link_paste_step(),
AppCommand::HardenStep => self.harden_step(),
// Pattern playback
AppCommand::QueuePatternChange(change) => {
self.playback.queued_changes.push(change);
// Pattern playback (staging)
AppCommand::StagePatternToggle { bank, pattern } => {
self.stage_pattern_toggle(bank, pattern, snapshot);
}
AppCommand::TogglePatternPlayback { bank, pattern } => {
self.toggle_pattern_playback(bank, pattern, snapshot);
AppCommand::CommitStagedChanges => {
self.commit_staged_changes();
}
AppCommand::ClearStagedChanges => {
self.clear_staged_changes();
}
// Project
@@ -859,6 +905,28 @@ impl App {
}
AppCommand::CloseModal => self.ui.modal = Modal::None,
AppCommand::OpenPatternModal(field) => self.open_pattern_modal(field),
AppCommand::OpenPatternPropsModal { bank, pattern } => {
self.open_pattern_props_modal(bank, pattern);
}
AppCommand::SetPatternProps {
bank,
pattern,
name,
length,
speed,
quantization,
sync_mode,
} => {
let pat = self.project_state.project.pattern_at_mut(bank, pattern);
pat.name = name;
if let Some(len) = length {
pat.set_length(len);
}
pat.speed = speed;
pat.quantization = quantization;
pat.sync_mode = sync_mode;
self.project_state.mark_dirty(bank, pattern);
}
// Page navigation
AppCommand::PageLeft => self.page.left(),
@@ -953,19 +1021,28 @@ impl App {
AppCommand::PatternsTogglePlay => {
let bank = self.patterns_nav.selected_bank();
let pattern = self.patterns_nav.selected_pattern();
self.toggle_pattern_playback(bank, pattern, snapshot);
self.stage_pattern_toggle(bank, pattern, snapshot);
}
}
}
pub fn flush_queued_changes(&mut self, cmd_tx: &Sender<SeqCommand>) {
for change in self.playback.queued_changes.drain(..) {
match change {
for staged in self.playback.queued_changes.drain(..) {
match staged.change {
PatternChange::Start { bank, pattern } => {
let _ = cmd_tx.send(SeqCommand::PatternStart { bank, pattern });
let _ = cmd_tx.send(SeqCommand::PatternStart {
bank,
pattern,
quantization: staged.quantization,
sync_mode: staged.sync_mode,
});
}
PatternChange::Stop { bank, pattern } => {
let _ = cmd_tx.send(SeqCommand::PatternStop { bank, pattern });
let _ = cmd_tx.send(SeqCommand::PatternStop {
bank,
pattern,
quantization: staged.quantization,
});
}
}
}
@@ -987,6 +1064,8 @@ impl App {
source: s.source,
})
.collect(),
quantization: pat.quantization,
sync_mode: pat.sync_mode,
};
let _ = cmd_tx.send(SeqCommand::PatternUpdate {
bank,

View File

@@ -1,7 +1,6 @@
use std::path::PathBuf;
use crate::engine::PatternChange;
use crate::model::PatternSpeed;
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
use crate::state::{FlashKind, Modal, PatternField};
#[allow(dead_code)]
@@ -74,12 +73,13 @@ pub enum AppCommand {
LinkPasteStep,
HardenStep,
// Pattern playback
QueuePatternChange(PatternChange),
TogglePatternPlayback {
// Pattern playback (staging)
StagePatternToggle {
bank: usize,
pattern: usize,
},
CommitStagedChanges,
ClearStagedChanges,
// Project
RenameBank {
@@ -105,6 +105,19 @@ pub enum AppCommand {
OpenModal(Modal),
CloseModal,
OpenPatternModal(PatternField),
OpenPatternPropsModal {
bank: usize,
pattern: usize,
},
SetPatternProps {
bank: usize,
pattern: usize,
name: Option<String>,
length: Option<usize>,
speed: PatternSpeed,
quantization: LaunchQuantization,
sync_mode: SyncMode,
},
// Page navigation
PageLeft,

View File

@@ -7,7 +7,7 @@ use std::time::Duration;
use thread_priority::{set_current_thread_priority, ThreadPriority};
use super::LinkState;
use crate::model::{MAX_BANKS, MAX_PATTERNS};
use crate::model::{LaunchQuantization, SyncMode, MAX_BANKS, MAX_PATTERNS};
use crate::model::{Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables};
use crate::state::LiveKeyState;
@@ -56,10 +56,13 @@ pub enum SeqCommand {
PatternStart {
bank: usize,
pattern: usize,
quantization: LaunchQuantization,
sync_mode: SyncMode,
},
PatternStop {
bank: usize,
pattern: usize,
quantization: LaunchQuantization,
},
Shutdown,
}
@@ -69,6 +72,8 @@ pub struct PatternSnapshot {
pub speed: crate::model::PatternSpeed,
pub length: usize,
pub steps: Vec<StepSnapshot>,
pub quantization: LaunchQuantization,
pub sync_mode: SyncMode,
}
#[derive(Clone)]
@@ -165,11 +170,18 @@ struct ActivePattern {
iter: usize,
}
#[derive(Clone, Copy)]
struct PendingPattern {
id: PatternId,
quantization: LaunchQuantization,
sync_mode: SyncMode,
}
struct AudioState {
prev_beat: f64,
active_patterns: HashMap<PatternId, ActivePattern>,
pending_starts: Vec<PatternId>,
pending_stops: Vec<PatternId>,
pending_starts: Vec<PendingPattern>,
pending_stops: Vec<PendingPattern>,
}
impl AudioState {
@@ -275,6 +287,41 @@ impl PatternSnapshot {
}
}
fn check_quantization_boundary(
quantization: LaunchQuantization,
beat: f64,
prev_beat: f64,
quantum: f64,
) -> bool {
if prev_beat < 0.0 {
return false;
}
match quantization {
LaunchQuantization::Immediate => true,
LaunchQuantization::Beat => beat.floor() as i64 != prev_beat.floor() as i64,
LaunchQuantization::Bar => {
let bar = (beat / quantum).floor() as i64;
let prev_bar = (prev_beat / quantum).floor() as i64;
bar != prev_bar
}
LaunchQuantization::Bars2 => {
let bars2 = (beat / (quantum * 2.0)).floor() as i64;
let prev_bars2 = (prev_beat / (quantum * 2.0)).floor() as i64;
bars2 != prev_bars2
}
LaunchQuantization::Bars4 => {
let bars4 = (beat / (quantum * 4.0)).floor() as i64;
let prev_bars4 = (prev_beat / (quantum * 4.0)).floor() as i64;
bars4 != prev_bars4
}
LaunchQuantization::Bars8 => {
let bars8 = (beat / (quantum * 8.0)).floor() as i64;
let prev_bars8 = (prev_beat / (quantum * 8.0)).floor() as i64;
bars8 != prev_bars8
}
}
}
type StepKey = (usize, usize, usize);
struct RunsCounter {
@@ -332,18 +379,35 @@ fn sequencer_loop(
} => {
pattern_cache.set(bank, pattern, data);
}
SeqCommand::PatternStart { bank, pattern } => {
SeqCommand::PatternStart {
bank,
pattern,
quantization,
sync_mode,
} => {
let id = PatternId { bank, pattern };
audio_state.pending_stops.retain(|p| *p != id);
if !audio_state.pending_starts.contains(&id) {
audio_state.pending_starts.push(id);
audio_state.pending_stops.retain(|p| p.id != id);
if !audio_state.pending_starts.iter().any(|p| p.id == id) {
audio_state.pending_starts.push(PendingPattern {
id,
quantization,
sync_mode,
});
}
}
SeqCommand::PatternStop { bank, pattern } => {
SeqCommand::PatternStop {
bank,
pattern,
quantization,
} => {
let id = PatternId { bank, pattern };
audio_state.pending_starts.retain(|p| *p != id);
if !audio_state.pending_stops.contains(&id) {
audio_state.pending_stops.push(id);
audio_state.pending_starts.retain(|p| p.id != id);
if !audio_state.pending_stops.iter().any(|p| p.id == id) {
audio_state.pending_stops.push(PendingPattern {
id,
quantization,
sync_mode: SyncMode::Reset,
});
}
}
SeqCommand::Shutdown => {
@@ -362,31 +426,67 @@ fn sequencer_loop(
let beat = state.beat_at_time(time, quantum);
let tempo = state.tempo();
let bar = (beat / quantum).floor() as i64;
let prev_bar = (audio_state.prev_beat / quantum).floor() as i64;
let prev_beat = audio_state.prev_beat;
let mut stopped_chain_keys: Vec<String> = Vec::new();
if bar != prev_bar && audio_state.prev_beat >= 0.0 {
for id in audio_state.pending_starts.drain(..) {
// Process pending starts with per-pattern quantization
let mut started_ids: Vec<PatternId> = Vec::new();
for pending in &audio_state.pending_starts {
let should_start = check_quantization_boundary(
pending.quantization,
beat,
prev_beat,
quantum,
);
if should_start {
let start_step = match pending.sync_mode {
SyncMode::Reset => 0,
SyncMode::PhaseLock => {
if let Some(pat) = pattern_cache.get(pending.id.bank, pending.id.pattern) {
let speed_mult = pat.speed.multiplier();
((beat * 4.0 * speed_mult) as usize) % pat.length
} else {
0
}
}
};
audio_state.active_patterns.insert(
id,
pending.id,
ActivePattern {
bank: id.bank,
pattern: id.pattern,
step_index: 0,
bank: pending.id.bank,
pattern: pending.id.pattern,
step_index: start_step,
iter: 0,
},
);
}
for id in audio_state.pending_stops.drain(..) {
audio_state.active_patterns.remove(&id);
step_traces.retain(|&(bank, pattern, _), _| {
bank != id.bank || pattern != id.pattern
});
stopped_chain_keys.push(format!("__chain_{}_{}__", id.bank, id.pattern));
started_ids.push(pending.id);
}
}
audio_state.pending_starts.retain(|p| !started_ids.contains(&p.id));
// Process pending stops with per-pattern quantization
let mut stopped_ids: Vec<PatternId> = Vec::new();
for pending in &audio_state.pending_stops {
let should_stop = check_quantization_boundary(
pending.quantization,
beat,
prev_beat,
quantum,
);
if should_stop {
audio_state.active_patterns.remove(&pending.id);
step_traces.retain(|&(bank, pattern, _), _| {
bank != pending.id.bank || pattern != pending.id.pattern
});
stopped_chain_keys.push(format!(
"__chain_{}_{}__",
pending.id.bank, pending.id.pattern
));
stopped_ids.push(pending.id);
}
}
audio_state.pending_stops.retain(|p| !stopped_ids.contains(&p.id));
let prev_beat = audio_state.prev_beat;
let mut chain_transitions: Vec<(PatternId, PatternId)> = Vec::new();
let mut chain_keys_to_remove: Vec<String> = Vec::new();
let mut new_tempo: Option<f64> = None;
@@ -515,13 +615,25 @@ fn sequencer_loop(
link.set_tempo(t);
}
// Apply chain transitions
// Apply chain transitions (use Bar quantization for chains)
for (source, target) in chain_transitions {
if !audio_state.pending_stops.contains(&source) {
audio_state.pending_stops.push(source);
if !audio_state.pending_stops.iter().any(|p| p.id == source) {
audio_state.pending_stops.push(PendingPattern {
id: source,
quantization: LaunchQuantization::Bar,
sync_mode: SyncMode::Reset,
});
}
if !audio_state.pending_starts.contains(&target) {
audio_state.pending_starts.push(target);
if !audio_state.pending_starts.iter().any(|p| p.id == target) {
let (quant, sync) = pattern_cache
.get(target.bank, target.pattern)
.map(|p| (p.quantization, p.sync_mode))
.unwrap_or((LaunchQuantization::Bar, SyncMode::Reset));
audio_state.pending_starts.push(PendingPattern {
id: target,
quantization: quant,
sync_mode: sync,
});
}
}

View File

@@ -10,7 +10,10 @@ use crate::commands::AppCommand;
use crate::engine::{AudioCommand, LinkState, SequencerSnapshot};
use crate::model::PatternSpeed;
use crate::page::Page;
use crate::state::{DeviceKind, EngineSection, Modal, OptionsFocus, PanelFocus, PatternField, SampleBrowserState, SettingKind, SidePanel};
use crate::state::{
DeviceKind, EngineSection, Modal, OptionsFocus, PanelFocus, PatternField, PatternPropsField,
SampleBrowserState, SettingKind, SidePanel,
};
pub enum InputResult {
Continue,
@@ -385,6 +388,7 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
},
Modal::Editor => {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let editor = &mut ctx.app.editor_ctx.editor;
if editor.search_active() {
@@ -400,7 +404,9 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
match key.code {
KeyCode::Esc => {
if editor.completion_active() {
if editor.is_selecting() {
editor.cancel_selection();
} else if editor.completion_active() {
editor.dismiss_completion();
} else {
ctx.dispatch(AppCommand::SaveEditorToStep);
@@ -421,6 +427,24 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
KeyCode::Char('p') if ctrl => {
editor.search_prev();
}
KeyCode::Char('a') if ctrl => {
editor.select_all();
}
KeyCode::Char('c') if ctrl => {
editor.copy();
}
KeyCode::Char('x') if ctrl => {
editor.cut();
}
KeyCode::Char('v') if ctrl => {
editor.paste();
}
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down if shift => {
if !editor.is_selecting() {
editor.start_selection();
}
editor.input(Event::Key(key));
}
_ => {
editor.input(Event::Key(key));
}
@@ -434,6 +458,71 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
KeyCode::Down => ctx.dispatch(AppCommand::StepDown),
_ => {}
},
Modal::PatternProps {
bank,
pattern,
field,
name,
length,
speed,
quantization,
sync_mode,
} => {
let (bank, pattern) = (*bank, *pattern);
match key.code {
KeyCode::Up => *field = field.prev(),
KeyCode::Down | KeyCode::Tab => *field = field.next(),
KeyCode::Left => match field {
PatternPropsField::Speed => *speed = speed.prev(),
PatternPropsField::Quantization => *quantization = quantization.prev(),
PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(),
_ => {}
},
KeyCode::Right => match field {
PatternPropsField::Speed => *speed = speed.next(),
PatternPropsField::Quantization => *quantization = quantization.next(),
PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(),
_ => {}
},
KeyCode::Char(c) => match field {
PatternPropsField::Name => name.push(c),
PatternPropsField::Length if c.is_ascii_digit() => length.push(c),
_ => {}
},
KeyCode::Backspace => match field {
PatternPropsField::Name => {
name.pop();
}
PatternPropsField::Length => {
length.pop();
}
_ => {}
},
KeyCode::Enter => {
let name_val = if name.is_empty() {
None
} else {
Some(name.clone())
};
let length_val = length.parse().ok();
let speed_val = *speed;
let quant_val = *quantization;
let sync_val = *sync_mode;
ctx.dispatch(AppCommand::SetPatternProps {
bank,
pattern,
name: name_val,
length: length_val,
speed: speed_val,
quantization: quant_val,
sync_mode: sync_val,
});
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
_ => {}
}
}
Modal::None => unreachable!(),
}
InputResult::Continue
@@ -660,9 +749,16 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
KeyCode::Right => ctx.dispatch(AppCommand::PatternsCursorRight),
KeyCode::Up => ctx.dispatch(AppCommand::PatternsCursorUp),
KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown),
KeyCode::Esc => ctx.dispatch(AppCommand::PatternsBack),
KeyCode::Esc => {
if !ctx.app.playback.staged_changes.is_empty() {
ctx.dispatch(AppCommand::ClearStagedChanges);
} else {
ctx.dispatch(AppCommand::PatternsBack);
}
}
KeyCode::Enter => ctx.dispatch(AppCommand::PatternsEnter),
KeyCode::Char(' ') => ctx.dispatch(AppCommand::PatternsTogglePlay),
KeyCode::Char('c') if !ctrl => ctx.dispatch(AppCommand::CommitStagedChanges),
KeyCode::Char('q') => {
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit {
selected: false,
@@ -738,6 +834,13 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
}
}
}
KeyCode::Char('e') if !ctrl => {
if ctx.app.patterns_nav.column == PatternsColumn::Patterns {
let bank = ctx.app.patterns_nav.bank_cursor;
let pattern = ctx.app.patterns_nav.pattern_cursor;
ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern });
}
}
_ => {}
}
InputResult::Continue

View File

@@ -71,9 +71,13 @@ fn main() -> io::Result<()> {
app.playback
.queued_changes
.push(engine::PatternChange::Start {
.push(crate::state::StagedChange {
change: engine::PatternChange::Start {
bank: 0,
pattern: 0,
},
quantization: crate::model::LaunchQuantization::Immediate,
sync_mode: crate::model::SyncMode::Reset,
});
app.audio.config.output_device = args.output.or(settings.audio.output_device);

View File

@@ -1,5 +1,8 @@
mod script;
pub use cagire_forth::{Word, WordCompile, WORDS};
pub use cagire_project::{load, save, Bank, Pattern, PatternSpeed, Project, MAX_BANKS, MAX_PATTERNS};
pub use cagire_project::{
load, save, Bank, LaunchQuantization, Pattern, PatternSpeed, Project, SyncMode, MAX_BANKS,
MAX_PATTERNS,
};
pub use script::{Dictionary, ExecutionTrace, Rng, ScriptEngine, SourceSpan, StepContext, Value, Variables};

View File

@@ -12,6 +12,38 @@ pub enum PatternField {
Speed,
}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum PatternPropsField {
#[default]
Name,
Length,
Speed,
Quantization,
SyncMode,
}
impl PatternPropsField {
pub fn next(&self) -> Self {
match self {
Self::Name => Self::Length,
Self::Length => Self::Speed,
Self::Speed => Self::Quantization,
Self::Quantization => Self::SyncMode,
Self::SyncMode => Self::SyncMode,
}
}
pub fn prev(&self) -> Self {
match self {
Self::Name => Self::Name,
Self::Length => Self::Name,
Self::Speed => Self::Length,
Self::Quantization => Self::Speed,
Self::SyncMode => Self::Quantization,
}
}
}
pub struct EditorContext {
pub bank: usize,
pub pattern: usize,

View File

@@ -13,12 +13,12 @@ pub mod ui;
pub use audio::{AudioSettings, DeviceKind, EngineSection, Metrics, SettingKind};
pub use options::{OptionsFocus, OptionsState};
pub use editor::{CopiedStep, EditorContext, Focus, PatternField};
pub use editor::{CopiedStep, EditorContext, Focus, PatternField, PatternPropsField};
pub use live_keys::LiveKeyState;
pub use modal::Modal;
pub use panel::{PanelFocus, PanelState, SidePanel};
pub use patterns_nav::{PatternsColumn, PatternsNav};
pub use playback::PlaybackState;
pub use playback::{PlaybackState, StagedChange};
pub use project::ProjectState;
pub use sample_browser::SampleBrowserState;
pub use ui::{DictFocus, FlashKind, UiState};

View File

@@ -1,4 +1,5 @@
use crate::state::editor::PatternField;
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
use crate::state::editor::{PatternField, PatternPropsField};
use crate::state::file_browser::FileBrowserState;
#[derive(Clone, PartialEq, Eq)]
@@ -40,4 +41,14 @@ pub enum Modal {
AddSamplePath(FileBrowserState),
Editor,
Preview,
PatternProps {
bank: usize,
pattern: usize,
field: PatternPropsField,
name: String,
length: String,
speed: PatternSpeed,
quantization: LaunchQuantization,
sync_mode: SyncMode,
},
}

View File

@@ -1,3 +1,5 @@
use crate::model::{MAX_BANKS, MAX_PATTERNS};
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum PatternsColumn {
#[default]
@@ -24,10 +26,10 @@ impl PatternsNav {
pub fn move_up(&mut self) {
match self.column {
PatternsColumn::Banks => {
self.bank_cursor = (self.bank_cursor + 15) % 16;
self.bank_cursor = (self.bank_cursor + MAX_BANKS - 1) % MAX_BANKS;
}
PatternsColumn::Patterns => {
self.pattern_cursor = (self.pattern_cursor + 15) % 16;
self.pattern_cursor = (self.pattern_cursor + MAX_PATTERNS - 1) % MAX_PATTERNS;
}
}
}
@@ -35,10 +37,10 @@ impl PatternsNav {
pub fn move_down(&mut self) {
match self.column {
PatternsColumn::Banks => {
self.bank_cursor = (self.bank_cursor + 1) % 16;
self.bank_cursor = (self.bank_cursor + 1) % MAX_BANKS;
}
PatternsColumn::Patterns => {
self.pattern_cursor = (self.pattern_cursor + 1) % 16;
self.pattern_cursor = (self.pattern_cursor + 1) % MAX_PATTERNS;
}
}
}

View File

@@ -1,14 +1,24 @@
use crate::engine::PatternChange;
use crate::model::{LaunchQuantization, SyncMode};
#[derive(Clone)]
pub struct StagedChange {
pub change: PatternChange,
pub quantization: LaunchQuantization,
pub sync_mode: SyncMode,
}
pub struct PlaybackState {
pub playing: bool,
pub queued_changes: Vec<PatternChange>,
pub staged_changes: Vec<StagedChange>,
pub queued_changes: Vec<StagedChange>,
}
impl Default for PlaybackState {
fn default() -> Self {
Self {
playing: true,
staged_changes: Vec::new(),
queued_changes: Vec::new(),
}
}

View File

@@ -6,6 +6,7 @@ use ratatui::Frame;
use crate::app::App;
use crate::engine::SequencerSnapshot;
use crate::model::{MAX_BANKS, MAX_PATTERNS};
use crate::state::PatternsColumn;
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
@@ -44,25 +45,25 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
.map(|p| p.bank)
.collect();
let banks_with_queued: Vec<usize> = app
let banks_with_staged: Vec<usize> = app
.playback
.queued_changes
.staged_changes
.iter()
.filter_map(|c| match c {
.filter_map(|c| match &c.change {
crate::engine::PatternChange::Start { bank, .. } => Some(*bank),
_ => None,
})
.collect();
let row_height = (inner.height / 16).max(1);
let total_needed = row_height * 16;
let row_height = (inner.height / MAX_BANKS as u16).max(1);
let total_needed = row_height * MAX_BANKS as u16;
let top_padding = if inner.height > total_needed {
(inner.height - total_needed) / 2
} else {
0
};
for idx in 0..16 {
for idx in 0..MAX_BANKS {
let y = inner.y + top_padding + (idx as u16) * row_height;
if y >= inner.y + inner.height {
break;
@@ -79,12 +80,12 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
let is_selected = idx == app.patterns_nav.bank_cursor;
let is_edit = idx == app.editor_ctx.bank;
let is_playing = banks_with_playback.contains(&idx);
let is_queued = banks_with_queued.contains(&idx);
let is_staged = banks_with_staged.contains(&idx);
let (bg, fg, prefix) = match (is_cursor, is_playing, is_queued) {
let (bg, fg, prefix) = match (is_cursor, is_playing, is_staged) {
(true, _, _) => (Color::Cyan, Color::Black, ""),
(false, true, _) => (Color::Rgb(45, 80, 45), Color::Green, "> "),
(false, false, true) => (Color::Rgb(80, 80, 45), Color::Yellow, "? "),
(false, false, true) => (Color::Rgb(80, 60, 100), Color::Magenta, "+ "),
(false, false, false) if is_selected => (Color::Rgb(60, 65, 75), Color::White, ""),
(false, false, false) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""),
(false, false, false) => (Color::Reset, Color::Rgb(120, 125, 135), ""),
@@ -101,7 +102,7 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
};
let style = Style::new().bg(bg).fg(fg);
let style = if is_playing || is_queued {
let style = if is_playing || is_staged {
style.add_modifier(Modifier::BOLD)
} else {
style
@@ -159,11 +160,11 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
.map(|p| p.pattern)
.collect();
let queued_to_play: Vec<usize> = app
let staged_to_play: Vec<usize> = app
.playback
.queued_changes
.staged_changes
.iter()
.filter_map(|c| match c {
.filter_map(|c| match &c.change {
crate::engine::PatternChange::Start {
bank: b, pattern, ..
} if *b == bank => Some(*pattern),
@@ -171,11 +172,11 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
})
.collect();
let queued_to_stop: Vec<usize> = app
let staged_to_stop: Vec<usize> = app
.playback
.queued_changes
.staged_changes
.iter()
.filter_map(|c| match c {
.filter_map(|c| match &c.change {
crate::engine::PatternChange::Stop {
bank: b,
pattern,
@@ -190,15 +191,15 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
None
};
let row_height = (inner.height / 16).max(1);
let total_needed = row_height * 16;
let row_height = (inner.height / MAX_PATTERNS as u16).max(1);
let total_needed = row_height * MAX_PATTERNS as u16;
let top_padding = if inner.height > total_needed {
(inner.height - total_needed) / 2
} else {
0
};
for idx in 0..16 {
for idx in 0..MAX_PATTERNS {
let y = inner.y + top_padding + (idx as u16) * row_height;
if y >= inner.y + inner.height {
break;
@@ -215,14 +216,14 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
let is_selected = idx == app.patterns_nav.pattern_cursor;
let is_edit = edit_pattern == Some(idx);
let is_playing = playing_patterns.contains(&idx);
let is_queued_play = queued_to_play.contains(&idx);
let is_queued_stop = queued_to_stop.contains(&idx);
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_queued_play, is_queued_stop) {
let (bg, fg, prefix) = match (is_cursor, is_playing, is_staged_play, is_staged_stop) {
(true, _, _, _) => (Color::Cyan, Color::Black, ""),
(false, true, _, true) => (Color::Rgb(120, 90, 30), Color::Yellow, "x "),
(false, true, _, true) => (Color::Rgb(120, 60, 80), Color::Magenta, "- "),
(false, true, _, false) => (Color::Rgb(45, 80, 45), Color::Green, "> "),
(false, false, true, _) => (Color::Rgb(80, 80, 45), Color::Yellow, "? "),
(false, false, true, _) => (Color::Rgb(80, 60, 100), Color::Magenta, "+ "),
(false, false, false, _) if is_selected => (Color::Rgb(60, 65, 75), Color::White, ""),
(false, false, false, _) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""),
(false, false, false, _) => (Color::Reset, Color::Rgb(120, 125, 135), ""),
@@ -271,7 +272,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
} else {
format!("{}{:02} {}", prefix, idx + 1, name)
};
let name_style = if is_playing || is_queued_play {
let name_style = if is_playing || is_staged_play {
bold_style
} else {
base_style

View File

@@ -3,7 +3,7 @@ use std::time::Instant;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
use crate::app::App;
@@ -626,5 +626,91 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
};
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area);
}
Modal::PatternProps {
bank,
pattern,
field,
name,
length,
speed,
quantization,
sync_mode,
} => {
use crate::state::PatternPropsField;
let width = 50u16;
let height = 12u16;
let x = (term.width.saturating_sub(width)) / 2;
let y = (term.height.saturating_sub(height)) / 2;
let area = Rect::new(x, y, width, height);
let block = Block::bordered()
.title(format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1))
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let fields = [
("Name", name.as_str(), *field == PatternPropsField::Name),
("Length", length.as_str(), *field == PatternPropsField::Length),
("Speed", speed.label(), *field == PatternPropsField::Speed),
(
"Quantization",
quantization.label(),
*field == PatternPropsField::Quantization,
),
(
"Sync Mode",
sync_mode.label(),
*field == PatternPropsField::SyncMode,
),
];
for (i, (label, value, selected)) in fields.iter().enumerate() {
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
let (label_style, value_style) = if *selected {
(
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
Style::default().fg(Color::White).bg(Color::DarkGray),
)
} else {
(
Style::default().fg(Color::Gray),
Style::default().fg(Color::White),
)
};
let label_area = Rect::new(inner.x + 1, y, 14, 1);
let value_area = Rect::new(inner.x + 16, y, inner.width.saturating_sub(18), 1);
frame.render_widget(
Paragraph::new(format!("{label}:")).style(label_style),
label_area,
);
frame.render_widget(
Paragraph::new(*value).style(value_style),
value_area,
);
}
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
let hint = Line::from(vec![
Span::styled("↑↓", Style::default().fg(Color::Yellow)),
Span::styled(" nav ", Style::default().fg(Color::DarkGray)),
Span::styled("←→", Style::default().fg(Color::Yellow)),
Span::styled(" change ", Style::default().fg(Color::DarkGray)),
Span::styled("Enter", Style::default().fg(Color::Yellow)),
Span::styled(" save ", Style::default().fg(Color::DarkGray)),
Span::styled("Esc", Style::default().fg(Color::Yellow)),
Span::styled(" cancel", Style::default().fg(Color::DarkGray)),
]);
frame.render_widget(Paragraph::new(hint), hint_area);
}
}
}