So much better
This commit is contained in:
@@ -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};
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,39 +346,30 @@ impl Editor {
|
||||
.map(|(row, line)| {
|
||||
let tokens = highlighter(row, line);
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
let mut col = 0;
|
||||
|
||||
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>();
|
||||
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);
|
||||
|
||||
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));
|
||||
}
|
||||
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));
|
||||
}
|
||||
col += text_len;
|
||||
}
|
||||
if cursor_col >= col {
|
||||
spans.push(Span::styled(" ", cursor_style));
|
||||
}
|
||||
} else {
|
||||
for (style, text) in tokens {
|
||||
spans.push(Span::styled(text, style));
|
||||
base_style
|
||||
};
|
||||
|
||||
spans.push(Span::styled(ch.to_string(), style));
|
||||
col += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if row == cursor_row && cursor_col >= col {
|
||||
spans.push(Span::styled(" ", cursor_style));
|
||||
}
|
||||
|
||||
Line::from(spans)
|
||||
})
|
||||
.collect();
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
131
src/app.rs
131
src/app.rs
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
109
src/input.rs
109
src/input.rs
@@ -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
|
||||
|
||||
10
src/main.rs
10
src/main.rs
@@ -71,9 +71,13 @@ fn main() -> io::Result<()> {
|
||||
|
||||
app.playback
|
||||
.queued_changes
|
||||
.push(engine::PatternChange::Start {
|
||||
bank: 0,
|
||||
pattern: 0,
|
||||
.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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user