Compare commits
4 Commits
8983b3f21c
...
2097997372
| Author | SHA1 | Date | |
|---|---|---|---|
| 2097997372 | |||
| 5579708f69 | |||
| 1b01491e87 | |||
| 5581ba1881 |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.0.6] - Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- TachyonFX based animations
|
||||||
|
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- PatternProps and EuclideanDistribution modals now use the global theme background instead of the terminal default.
|
||||||
|
- Changing pattern properties is now a stage/commit operation.
|
||||||
|
- Changing pattern speed only happens at pattern boundaries.
|
||||||
|
- `mlockall` warning no longer appears on macOS; memory locking is now Linux-only.
|
||||||
|
|
||||||
## [0.0.5] - Unreleased
|
## [0.0.5] - Unreleased
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"]
|
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.0.4"
|
version = "0.0.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
|
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
@@ -58,6 +58,7 @@ clap = { version = "4", features = ["derive"] }
|
|||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
tachyonfx = { version = "0.22", features = ["std-duration"] }
|
||||||
tui-big-text = "0.8"
|
tui-big-text = "0.8"
|
||||||
arboard = "3"
|
arboard = "3"
|
||||||
minimad = "0.13"
|
minimad = "0.13"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ impl<'a> ConfirmModal<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_centered(self, frame: &mut Frame, term: Rect) {
|
pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect {
|
||||||
let t = theme::get();
|
let t = theme::get();
|
||||||
let inner = ModalFrame::new(self.title)
|
let inner = ModalFrame::new(self.title)
|
||||||
.width(30)
|
.width(30)
|
||||||
@@ -58,5 +58,7 @@ impl<'a> ConfirmModal<'a> {
|
|||||||
Paragraph::new(buttons).alignment(Alignment::Center),
|
Paragraph::new(buttons).alignment(Alignment::Center),
|
||||||
rows[1],
|
rows[1],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
inner
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ impl<'a> FileBrowserModal<'a> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_centered(self, frame: &mut Frame, term: Rect) {
|
pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect {
|
||||||
let colors = theme::get();
|
let colors = theme::get();
|
||||||
let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
|
let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
|
||||||
|
|
||||||
@@ -112,5 +112,7 @@ impl<'a> FileBrowserModal<'a> {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
frame.render_widget(Paragraph::new(lines), rows[1]);
|
frame.render_widget(Paragraph::new(lines), rows[1]);
|
||||||
|
|
||||||
|
inner
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ impl<'a> TextInputModal<'a> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_centered(self, frame: &mut Frame, term: Rect) {
|
pub fn render_centered(self, frame: &mut Frame, term: Rect) -> Rect {
|
||||||
let colors = theme::get();
|
let colors = theme::get();
|
||||||
let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
|
let border_color = self.border_color.unwrap_or(colors.ui.text_primary);
|
||||||
let height = if self.hint.is_some() { 6 } else { 5 };
|
let height = if self.hint.is_some() { 6 } else { 5 };
|
||||||
@@ -81,5 +81,7 @@ impl<'a> TextInputModal<'a> {
|
|||||||
inner,
|
inner,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inner
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/app.rs
64
src/app.rs
@@ -20,7 +20,7 @@ use crate::settings::Settings;
|
|||||||
use crate::state::{
|
use crate::state::{
|
||||||
AudioSettings, CyclicEnum, DictFocus, EditorContext, FlashKind, LiveKeyState, Metrics, Modal,
|
AudioSettings, CyclicEnum, DictFocus, EditorContext, FlashKind, LiveKeyState, Metrics, Modal,
|
||||||
MuteState, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav,
|
MuteState, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav,
|
||||||
PlaybackState, ProjectState, StagedChange, UiState,
|
PlaybackState, ProjectState, StagedChange, StagedPropChange, UiState,
|
||||||
};
|
};
|
||||||
use crate::views::{dict_view, help_view};
|
use crate::views::{dict_view, help_view};
|
||||||
|
|
||||||
@@ -499,13 +499,14 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commits staged pattern and mute/solo changes.
|
/// Commits staged pattern, mute/solo, and prop changes.
|
||||||
/// Returns true if mute state changed (caller should send to sequencer).
|
/// Returns true if mute state changed (caller should send to sequencer).
|
||||||
pub fn commit_staged_changes(&mut self) -> bool {
|
pub fn commit_staged_changes(&mut self) -> bool {
|
||||||
let pattern_count = self.playback.staged_changes.len();
|
let pattern_count = self.playback.staged_changes.len();
|
||||||
let mute_count = self.playback.staged_mute_changes.len();
|
let mute_count = self.playback.staged_mute_changes.len();
|
||||||
|
let prop_count = self.playback.staged_prop_changes.len();
|
||||||
|
|
||||||
if pattern_count == 0 && mute_count == 0 {
|
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 commit".to_string());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -530,11 +531,21 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let status = match (pattern_count, mute_count) {
|
// Apply staged prop changes
|
||||||
(0, m) => format!("Applied {m} mute/solo changes"),
|
for ((bank, pattern), props) in self.playback.staged_prop_changes.drain() {
|
||||||
(p, 0) => format!("Committed {p} pattern changes"),
|
let pat = self.project_state.project.pattern_at_mut(bank, pattern);
|
||||||
(p, m) => format!("Committed {p} pattern + {m} mute/solo changes"),
|
pat.name = props.name;
|
||||||
};
|
if let Some(len) = props.length {
|
||||||
|
pat.set_length(len);
|
||||||
|
}
|
||||||
|
pat.speed = props.speed;
|
||||||
|
pat.quantization = props.quantization;
|
||||||
|
pat.sync_mode = props.sync_mode;
|
||||||
|
self.project_state.mark_dirty(bank, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = pattern_count + mute_count + prop_count;
|
||||||
|
let status = format!("Committed {total} changes");
|
||||||
self.ui.set_status(status);
|
self.ui.set_status(status);
|
||||||
|
|
||||||
mute_changed
|
mute_changed
|
||||||
@@ -543,19 +554,18 @@ impl App {
|
|||||||
pub fn clear_staged_changes(&mut self) {
|
pub fn clear_staged_changes(&mut self) {
|
||||||
let pattern_count = self.playback.staged_changes.len();
|
let pattern_count = self.playback.staged_changes.len();
|
||||||
let mute_count = self.playback.staged_mute_changes.len();
|
let mute_count = self.playback.staged_mute_changes.len();
|
||||||
|
let prop_count = self.playback.staged_prop_changes.len();
|
||||||
|
|
||||||
if pattern_count == 0 && mute_count == 0 {
|
if pattern_count == 0 && mute_count == 0 && prop_count == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.playback.staged_changes.clear();
|
self.playback.staged_changes.clear();
|
||||||
self.playback.staged_mute_changes.clear();
|
self.playback.staged_mute_changes.clear();
|
||||||
|
self.playback.staged_prop_changes.clear();
|
||||||
|
|
||||||
let status = match (pattern_count, mute_count) {
|
let total = pattern_count + mute_count + prop_count;
|
||||||
(0, m) => format!("Cleared {m} staged mute/solo"),
|
let status = format!("Cleared {total} staged changes");
|
||||||
(p, 0) => format!("Cleared {p} staged patterns"),
|
|
||||||
(p, m) => format!("Cleared {p} patterns + {m} mute/solo"),
|
|
||||||
};
|
|
||||||
self.ui.set_status(status);
|
self.ui.set_status(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1189,7 +1199,7 @@ impl App {
|
|||||||
AppCommand::OpenPatternPropsModal { bank, pattern } => {
|
AppCommand::OpenPatternPropsModal { bank, pattern } => {
|
||||||
self.open_pattern_props_modal(bank, pattern);
|
self.open_pattern_props_modal(bank, pattern);
|
||||||
}
|
}
|
||||||
AppCommand::SetPatternProps {
|
AppCommand::StagePatternProps {
|
||||||
bank,
|
bank,
|
||||||
pattern,
|
pattern,
|
||||||
name,
|
name,
|
||||||
@@ -1198,15 +1208,21 @@ impl App {
|
|||||||
quantization,
|
quantization,
|
||||||
sync_mode,
|
sync_mode,
|
||||||
} => {
|
} => {
|
||||||
let pat = self.project_state.project.pattern_at_mut(bank, pattern);
|
self.playback.staged_prop_changes.insert(
|
||||||
pat.name = name;
|
(bank, pattern),
|
||||||
if let Some(len) = length {
|
StagedPropChange {
|
||||||
pat.set_length(len);
|
name,
|
||||||
}
|
length,
|
||||||
pat.speed = speed;
|
speed,
|
||||||
pat.quantization = quantization;
|
quantization,
|
||||||
pat.sync_mode = sync_mode;
|
sync_mode,
|
||||||
self.project_state.mark_dirty(bank, pattern);
|
},
|
||||||
|
);
|
||||||
|
self.ui.set_status(format!(
|
||||||
|
"B{:02}:P{:02} props staged",
|
||||||
|
bank + 1,
|
||||||
|
pattern + 1
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page navigation
|
// Page navigation
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ struct CagireDesktop {
|
|||||||
mouse_x: Arc<AtomicU32>,
|
mouse_x: Arc<AtomicU32>,
|
||||||
mouse_y: Arc<AtomicU32>,
|
mouse_y: Arc<AtomicU32>,
|
||||||
mouse_down: Arc<AtomicU32>,
|
mouse_down: Arc<AtomicU32>,
|
||||||
|
last_frame: std::time::Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CagireDesktop {
|
impl CagireDesktop {
|
||||||
@@ -285,6 +286,7 @@ impl CagireDesktop {
|
|||||||
mouse_x,
|
mouse_x,
|
||||||
mouse_y,
|
mouse_y,
|
||||||
mouse_down,
|
mouse_down,
|
||||||
|
last_frame: std::time::Instant::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,10 +493,15 @@ impl eframe::App for CagireDesktop {
|
|||||||
self.app.ui.sparkles.tick(self.terminal.get_frame().area());
|
self.app.ui.sparkles.tick(self.terminal.get_frame().area());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cagire::state::effects::tick_effects(&mut self.app.ui, self.app.page);
|
||||||
|
|
||||||
|
let elapsed = self.last_frame.elapsed();
|
||||||
|
self.last_frame = std::time::Instant::now();
|
||||||
|
|
||||||
let link = &self.link;
|
let link = &self.link;
|
||||||
let app = &self.app;
|
let app = &self.app;
|
||||||
self.terminal
|
self.terminal
|
||||||
.draw(|frame| views::render(frame, app, link, &seq_snapshot))
|
.draw(|frame| views::render(frame, app, link, &seq_snapshot, elapsed))
|
||||||
.expect("Failed to draw");
|
.expect("Failed to draw");
|
||||||
|
|
||||||
ui.add(self.terminal.backend_mut());
|
ui.add(self.terminal.backend_mut());
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ pub enum AppCommand {
|
|||||||
bank: usize,
|
bank: usize,
|
||||||
pattern: usize,
|
pattern: usize,
|
||||||
},
|
},
|
||||||
SetPatternProps {
|
StagePatternProps {
|
||||||
bank: usize,
|
bank: usize,
|
||||||
pattern: usize,
|
pattern: usize,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
|
|||||||
@@ -1,45 +1,52 @@
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
#[cfg(target_os = "linux")]
|
||||||
|
mod memory {
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
static MLOCKALL_CALLED: AtomicBool = AtomicBool::new(false);
|
static MLOCKALL_CALLED: AtomicBool = AtomicBool::new(false);
|
||||||
static MLOCKALL_SUCCESS: AtomicBool = AtomicBool::new(false);
|
static MLOCKALL_SUCCESS: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
/// Locks all current and future memory pages to prevent page faults during RT execution.
|
/// Locks all current and future memory pages to prevent page faults during RT execution.
|
||||||
/// Must be called BEFORE spawning any threads for maximum effectiveness.
|
/// Must be called BEFORE spawning any threads for maximum effectiveness.
|
||||||
/// Returns true if mlockall succeeded, false otherwise (which is common without rtprio).
|
pub fn lock_memory() -> bool {
|
||||||
#[cfg(unix)]
|
if MLOCKALL_CALLED.swap(true, Ordering::SeqCst) {
|
||||||
pub fn lock_memory() -> bool {
|
return MLOCKALL_SUCCESS.load(Ordering::SeqCst);
|
||||||
if MLOCKALL_CALLED.swap(true, Ordering::SeqCst) {
|
}
|
||||||
return MLOCKALL_SUCCESS.load(Ordering::SeqCst);
|
|
||||||
|
let result = unsafe { libc::mlockall(libc::MCL_CURRENT | libc::MCL_FUTURE) };
|
||||||
|
|
||||||
|
if result == 0 {
|
||||||
|
MLOCKALL_SUCCESS.store(true, Ordering::SeqCst);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
let errno = std::io::Error::last_os_error();
|
||||||
|
eprintln!("[cagire] mlockall failed: {errno}");
|
||||||
|
eprintln!("[cagire] Memory locking disabled. For best RT performance on Linux:");
|
||||||
|
eprintln!("[cagire] 1. Add user to 'audio' group: sudo usermod -aG audio $USER");
|
||||||
|
eprintln!("[cagire] 2. Add to /etc/security/limits.conf:");
|
||||||
|
eprintln!("[cagire] @audio - memlock unlimited");
|
||||||
|
eprintln!("[cagire] 3. Log out and back in");
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = unsafe { libc::mlockall(libc::MCL_CURRENT | libc::MCL_FUTURE) };
|
#[allow(dead_code)]
|
||||||
|
pub fn is_memory_locked() -> bool {
|
||||||
if result == 0 {
|
MLOCKALL_SUCCESS.load(Ordering::Relaxed)
|
||||||
MLOCKALL_SUCCESS.store(true, Ordering::SeqCst);
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
// Get the actual error for better diagnostics
|
|
||||||
let errno = std::io::Error::last_os_error();
|
|
||||||
eprintln!("[cagire] mlockall failed: {errno}");
|
|
||||||
eprintln!("[cagire] Memory locking disabled. For best RT performance on Linux:");
|
|
||||||
eprintln!("[cagire] 1. Add user to 'audio' group: sudo usermod -aG audio $USER");
|
|
||||||
eprintln!("[cagire] 2. Add to /etc/security/limits.conf:");
|
|
||||||
eprintln!("[cagire] @audio - memlock unlimited");
|
|
||||||
eprintln!("[cagire] 3. Log out and back in");
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(target_os = "linux")]
|
||||||
|
pub use memory::{is_memory_locked, lock_memory};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
pub fn lock_memory() -> bool {
|
pub fn lock_memory() -> bool {
|
||||||
// Windows: VirtualLock exists but isn't typically needed for audio
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if memory locking is active.
|
#[cfg(not(target_os = "linux"))]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn is_memory_locked() -> bool {
|
pub fn is_memory_locked() -> bool {
|
||||||
MLOCKALL_SUCCESS.load(Ordering::Relaxed)
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts to set realtime scheduling priority for the current thread.
|
/// Attempts to set realtime scheduling priority for the current thread.
|
||||||
|
|||||||
@@ -531,6 +531,7 @@ impl KeyCache {
|
|||||||
pub(crate) struct SequencerState {
|
pub(crate) struct SequencerState {
|
||||||
audio_state: AudioState,
|
audio_state: AudioState,
|
||||||
pattern_cache: PatternCache,
|
pattern_cache: PatternCache,
|
||||||
|
pending_updates: HashMap<(usize, usize), PatternSnapshot>,
|
||||||
runs_counter: RunsCounter,
|
runs_counter: RunsCounter,
|
||||||
step_traces: Arc<StepTracesMap>,
|
step_traces: Arc<StepTracesMap>,
|
||||||
event_count: usize,
|
event_count: usize,
|
||||||
@@ -559,6 +560,7 @@ impl SequencerState {
|
|||||||
Self {
|
Self {
|
||||||
audio_state: AudioState::new(),
|
audio_state: AudioState::new(),
|
||||||
pattern_cache: PatternCache::new(),
|
pattern_cache: PatternCache::new(),
|
||||||
|
pending_updates: HashMap::new(),
|
||||||
runs_counter: RunsCounter::new(),
|
runs_counter: RunsCounter::new(),
|
||||||
step_traces: Arc::new(HashMap::new()),
|
step_traces: Arc::new(HashMap::new()),
|
||||||
event_count: 0,
|
event_count: 0,
|
||||||
@@ -596,7 +598,14 @@ impl SequencerState {
|
|||||||
pattern,
|
pattern,
|
||||||
data,
|
data,
|
||||||
} => {
|
} => {
|
||||||
self.pattern_cache.set(bank, pattern, data);
|
let id = PatternId { bank, pattern };
|
||||||
|
let is_active = self.audio_state.active_patterns.contains_key(&id);
|
||||||
|
let has_cache = self.pattern_cache.get(bank, pattern).is_some();
|
||||||
|
if is_active && has_cache {
|
||||||
|
self.pending_updates.insert((bank, pattern), data);
|
||||||
|
} else {
|
||||||
|
self.pattern_cache.set(bank, pattern, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SeqCommand::PatternStart {
|
SeqCommand::PatternStart {
|
||||||
bank,
|
bank,
|
||||||
@@ -652,6 +661,10 @@ impl SequencerState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
SeqCommand::StopAll => {
|
SeqCommand::StopAll => {
|
||||||
|
// Flush pending updates so cache stays current for future launches
|
||||||
|
for ((bank, pattern), snapshot) in self.pending_updates.drain() {
|
||||||
|
self.pattern_cache.set(bank, pattern, snapshot);
|
||||||
|
}
|
||||||
self.audio_state.active_patterns.clear();
|
self.audio_state.active_patterns.clear();
|
||||||
self.audio_state.pending_starts.clear();
|
self.audio_state.pending_starts.clear();
|
||||||
self.audio_state.pending_stops.clear();
|
self.audio_state.pending_stops.clear();
|
||||||
@@ -715,6 +728,10 @@ impl SequencerState {
|
|||||||
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
|
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
|
||||||
bank != pending.id.bank || pattern != pending.id.pattern
|
bank != pending.id.bank || pattern != pending.id.pattern
|
||||||
});
|
});
|
||||||
|
let key = (pending.id.bank, pending.id.pattern);
|
||||||
|
if let Some(snapshot) = self.pending_updates.remove(&key) {
|
||||||
|
self.pattern_cache.set(key.0, key.1, snapshot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.audio_state.pending_starts.clear();
|
self.audio_state.pending_starts.clear();
|
||||||
self.audio_state.prev_beat = -1.0;
|
self.audio_state.prev_beat = -1.0;
|
||||||
@@ -773,6 +790,11 @@ impl SequencerState {
|
|||||||
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
|
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
|
||||||
bank != pending.id.bank || pattern != pending.id.pattern
|
bank != pending.id.bank || pattern != pending.id.pattern
|
||||||
});
|
});
|
||||||
|
// Flush pending update so cache stays current for future launches
|
||||||
|
let key = (pending.id.bank, pending.id.pattern);
|
||||||
|
if let Some(snapshot) = self.pending_updates.remove(&key) {
|
||||||
|
self.pattern_cache.set(key.0, key.1, snapshot);
|
||||||
|
}
|
||||||
self.buf_stopped.push(pending.id);
|
self.buf_stopped.push(pending.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -921,6 +943,14 @@ impl SequencerState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply deferred updates for patterns that just completed an iteration
|
||||||
|
for completed_id in &self.buf_completed_iterations {
|
||||||
|
let key = (completed_id.bank, completed_id.pattern);
|
||||||
|
if let Some(snapshot) = self.pending_updates.remove(&key) {
|
||||||
|
self.pattern_cache.set(key.0, key.1, snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1872,8 +1902,9 @@ mod tests {
|
|||||||
let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap();
|
let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap();
|
||||||
assert_eq!(ap.step_index, 0);
|
assert_eq!(ap.step_index, 0);
|
||||||
|
|
||||||
// Update pattern to length 2 while running — step_index wraps via modulo
|
// Update pattern to length 2 while running — deferred until iteration boundary
|
||||||
// beat=1.25: beat_int=5, prev=4, fires 1 step. step_index=0%2=0 fires, advances to 1
|
// beat=1.25: update is deferred (pattern active), still length 4
|
||||||
|
// step_index=0 fires, advances to 1
|
||||||
state.tick(tick_with(
|
state.tick(tick_with(
|
||||||
vec![SeqCommand::PatternUpdate {
|
vec![SeqCommand::PatternUpdate {
|
||||||
bank: 0,
|
bank: 0,
|
||||||
@@ -1883,10 +1914,21 @@ mod tests {
|
|||||||
1.25,
|
1.25,
|
||||||
));
|
));
|
||||||
let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap();
|
let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap();
|
||||||
|
assert_eq!(ap.step_index, 1); // still length 4
|
||||||
|
|
||||||
|
// Advance through remaining steps of original length-4 pattern
|
||||||
|
state.tick(tick_at(1.5, true)); // step 1→2
|
||||||
|
state.tick(tick_at(1.75, true)); // step 2→3
|
||||||
|
state.tick(tick_at(2.0, true)); // step 3→wraps to 0, iteration completes, update applies
|
||||||
|
|
||||||
|
// Now length=2 is applied. Next tick uses new length.
|
||||||
|
// beat=2.25: step 0 fires, advances to 1
|
||||||
|
state.tick(tick_at(2.25, true));
|
||||||
|
let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap();
|
||||||
assert_eq!(ap.step_index, 1);
|
assert_eq!(ap.step_index, 1);
|
||||||
|
|
||||||
// beat=1.5: beat_int=6, prev=5, step fires. step_index=1 fires, wraps to 0
|
// beat=2.5: step 1 fires, wraps to 0 (length 2)
|
||||||
state.tick(tick_at(1.5, true));
|
state.tick(tick_at(2.5, true));
|
||||||
let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap();
|
let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap();
|
||||||
assert_eq!(ap.step_index, 0);
|
assert_eq!(ap.step_index, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -615,7 +615,7 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
let speed_val = *speed;
|
let speed_val = *speed;
|
||||||
let quant_val = *quantization;
|
let quant_val = *quantization;
|
||||||
let sync_val = *sync_mode;
|
let sync_val = *sync_mode;
|
||||||
ctx.dispatch(AppCommand::SetPatternProps {
|
ctx.dispatch(AppCommand::StagePatternProps {
|
||||||
bank,
|
bank,
|
||||||
pattern,
|
pattern,
|
||||||
name: name_val,
|
name: name_val,
|
||||||
@@ -1096,6 +1096,7 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
if !ctx.app.playback.staged_changes.is_empty()
|
if !ctx.app.playback.staged_changes.is_empty()
|
||||||
|| !ctx.app.playback.staged_mute_changes.is_empty()
|
|| !ctx.app.playback.staged_mute_changes.is_empty()
|
||||||
|
|| !ctx.app.playback.staged_prop_changes.is_empty()
|
||||||
{
|
{
|
||||||
ctx.dispatch(AppCommand::ClearStagedChanges);
|
ctx.dispatch(AppCommand::ClearStagedChanges);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
16
src/main.rs
16
src/main.rs
@@ -16,7 +16,7 @@ use std::io;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use crossterm::event::{self, DisableBracketedPaste, EnableBracketedPaste, Event};
|
use crossterm::event::{self, DisableBracketedPaste, EnableBracketedPaste, Event};
|
||||||
@@ -213,6 +213,8 @@ fn main() -> io::Result<()> {
|
|||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
terminal.clear()?;
|
terminal.clear()?;
|
||||||
|
|
||||||
|
let mut last_frame = Instant::now();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if app.audio.restart_pending {
|
if app.audio.restart_pending {
|
||||||
app.audio.restart_pending = false;
|
app.audio.restart_pending = false;
|
||||||
@@ -369,11 +371,19 @@ fn main() -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if app.playback.playing || had_event || app.ui.show_title {
|
state::effects::tick_effects(&mut app.ui, app.page);
|
||||||
|
|
||||||
|
let elapsed = last_frame.elapsed();
|
||||||
|
last_frame = Instant::now();
|
||||||
|
|
||||||
|
let effects_active = app.ui.effects.borrow().is_running()
|
||||||
|
|| app.ui.modal_fx.borrow().is_some()
|
||||||
|
|| app.ui.title_fx.borrow().is_some();
|
||||||
|
if app.playback.playing || had_event || app.ui.show_title || effects_active {
|
||||||
if app.ui.show_title {
|
if app.ui.show_title {
|
||||||
app.ui.sparkles.tick(terminal.get_frame().area());
|
app.ui.sparkles.tick(terminal.get_frame().area());
|
||||||
}
|
}
|
||||||
terminal.draw(|frame| views::render(frame, &app, &link, &seq_snapshot))?;
|
terminal.draw(|frame| views::render(frame, &app, &link, &seq_snapshot, elapsed))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,14 @@ impl Default for LinkSettings {
|
|||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
confy::load(APP_NAME, None).unwrap_or_default()
|
let mut settings: Self = confy::load(APP_NAME, None).unwrap_or_default();
|
||||||
|
if settings.audio.channels == 0 {
|
||||||
|
settings.audio.channels = AudioSettings::default().channels;
|
||||||
|
}
|
||||||
|
if settings.audio.buffer_size == 0 {
|
||||||
|
settings.audio.buffer_size = AudioSettings::default().buffer_size;
|
||||||
|
}
|
||||||
|
settings
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self) {
|
pub fn save(&self) {
|
||||||
|
|||||||
54
src/state/effects.rs
Normal file
54
src/state/effects.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use tachyonfx::{fx, Interpolation, Motion};
|
||||||
|
|
||||||
|
use crate::page::Page;
|
||||||
|
use crate::state::ui::UiState;
|
||||||
|
use crate::state::Modal;
|
||||||
|
use crate::theme;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum FxId {
|
||||||
|
#[default]
|
||||||
|
PageTransition,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tick_effects(ui: &mut UiState, page: Page) {
|
||||||
|
if !ui.show_title && ui.prev_show_title {
|
||||||
|
ui.effects.borrow_mut().add_unique_effect(
|
||||||
|
FxId::PageTransition,
|
||||||
|
fx::coalesce((200, Interpolation::QuadOut)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ui.prev_show_title = ui.show_title;
|
||||||
|
|
||||||
|
let modal_open = !matches!(ui.modal, Modal::None);
|
||||||
|
|
||||||
|
if modal_open && !ui.prev_modal_open {
|
||||||
|
let bg = theme::get().ui.bg;
|
||||||
|
*ui.modal_fx.borrow_mut() = Some(fx::fade_from_fg(bg, (50, Interpolation::QuadOut)));
|
||||||
|
}
|
||||||
|
ui.prev_modal_open = modal_open;
|
||||||
|
|
||||||
|
if page != ui.prev_page {
|
||||||
|
let direction = page_direction(ui.prev_page, page);
|
||||||
|
let bg = theme::get().ui.bg;
|
||||||
|
ui.effects.borrow_mut().add_unique_effect(
|
||||||
|
FxId::PageTransition,
|
||||||
|
fx::sweep_in(direction, 10, 0, bg, (200, Interpolation::QuadOut)),
|
||||||
|
);
|
||||||
|
ui.prev_page = page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page_direction(from: Page, to: Page) -> Motion {
|
||||||
|
let (fc, fr) = from.grid_pos();
|
||||||
|
let (tc, tr) = to.grid_pos();
|
||||||
|
if tc > fc {
|
||||||
|
Motion::LeftToRight
|
||||||
|
} else if tc < fc {
|
||||||
|
Motion::RightToLeft
|
||||||
|
} else if tr > fr {
|
||||||
|
Motion::UpToDown
|
||||||
|
} else {
|
||||||
|
Motion::DownToUp
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ pub trait CyclicEnum: Sized + Copy + PartialEq + 'static {
|
|||||||
pub mod audio;
|
pub mod audio;
|
||||||
pub mod color_scheme;
|
pub mod color_scheme;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
|
pub mod effects;
|
||||||
pub mod file_browser;
|
pub mod file_browser;
|
||||||
pub mod live_keys;
|
pub mod live_keys;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
@@ -40,7 +41,7 @@ pub use options::{OptionsFocus, OptionsState};
|
|||||||
pub use panel::{PanelFocus, PanelState, SidePanel};
|
pub use panel::{PanelFocus, PanelState, SidePanel};
|
||||||
pub use patterns_nav::{PatternsColumn, PatternsNav};
|
pub use patterns_nav::{PatternsColumn, PatternsNav};
|
||||||
pub use mute::MuteState;
|
pub use mute::MuteState;
|
||||||
pub use playback::{PlaybackState, StagedChange, StagedMuteChange};
|
pub use playback::{PlaybackState, StagedChange, StagedMuteChange, StagedPropChange};
|
||||||
pub use project::ProjectState;
|
pub use project::ProjectState;
|
||||||
pub use sample_browser::SampleBrowserState;
|
pub use sample_browser::SampleBrowserState;
|
||||||
pub use ui::{DictFocus, FlashKind, HelpFocus, UiState};
|
pub use ui::{DictFocus, FlashKind, HelpFocus, UiState};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::engine::PatternChange;
|
use crate::engine::PatternChange;
|
||||||
use crate::model::{LaunchQuantization, SyncMode};
|
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct StagedChange {
|
pub struct StagedChange {
|
||||||
@@ -15,11 +15,20 @@ pub enum StagedMuteChange {
|
|||||||
ToggleSolo { bank: usize, pattern: usize },
|
ToggleSolo { bank: usize, pattern: usize },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct StagedPropChange {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub length: Option<usize>,
|
||||||
|
pub speed: PatternSpeed,
|
||||||
|
pub quantization: LaunchQuantization,
|
||||||
|
pub sync_mode: SyncMode,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PlaybackState {
|
pub struct PlaybackState {
|
||||||
pub playing: bool,
|
pub playing: bool,
|
||||||
pub staged_changes: Vec<StagedChange>,
|
pub staged_changes: Vec<StagedChange>,
|
||||||
pub queued_changes: Vec<StagedChange>,
|
pub queued_changes: Vec<StagedChange>,
|
||||||
pub staged_mute_changes: HashSet<StagedMuteChange>,
|
pub staged_mute_changes: HashSet<StagedMuteChange>,
|
||||||
|
pub staged_prop_changes: HashMap<(usize, usize), StagedPropChange>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PlaybackState {
|
impl Default for PlaybackState {
|
||||||
@@ -29,6 +38,7 @@ impl Default for PlaybackState {
|
|||||||
staged_changes: Vec::new(),
|
staged_changes: Vec::new(),
|
||||||
queued_changes: Vec::new(),
|
queued_changes: Vec::new(),
|
||||||
staged_mute_changes: HashSet::new(),
|
staged_mute_changes: HashSet::new(),
|
||||||
|
staged_prop_changes: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,6 +51,11 @@ impl PlaybackState {
|
|||||||
pub fn clear_queues(&mut self) {
|
pub fn clear_queues(&mut self) {
|
||||||
self.staged_changes.clear();
|
self.staged_changes.clear();
|
||||||
self.queued_changes.clear();
|
self.queued_changes.clear();
|
||||||
|
self.staged_prop_changes.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_staged_props(&self, bank: usize, pattern: usize) -> bool {
|
||||||
|
self.staged_prop_changes.contains_key(&(bank, pattern))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stage_mute(&mut self, bank: usize, pattern: usize) {
|
pub fn stage_mute(&mut self, bank: usize, pattern: usize) {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use cagire_ratatui::Sparkles;
|
use cagire_ratatui::Sparkles;
|
||||||
|
use tachyonfx::{fx, Effect, EffectManager, Interpolation};
|
||||||
|
|
||||||
|
use crate::page::Page;
|
||||||
|
use crate::state::effects::FxId;
|
||||||
use crate::state::{ColorScheme, Modal};
|
use crate::state::{ColorScheme, Modal};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
@@ -48,6 +52,12 @@ pub struct UiState {
|
|||||||
pub minimap_until: Option<Instant>,
|
pub minimap_until: Option<Instant>,
|
||||||
pub color_scheme: ColorScheme,
|
pub color_scheme: ColorScheme,
|
||||||
pub hue_rotation: f32,
|
pub hue_rotation: f32,
|
||||||
|
pub effects: RefCell<EffectManager<FxId>>,
|
||||||
|
pub modal_fx: RefCell<Option<Effect>>,
|
||||||
|
pub title_fx: RefCell<Option<Effect>>,
|
||||||
|
pub prev_modal_open: bool,
|
||||||
|
pub prev_page: Page,
|
||||||
|
pub prev_show_title: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiState {
|
impl Default for UiState {
|
||||||
@@ -74,6 +84,12 @@ impl Default for UiState {
|
|||||||
minimap_until: None,
|
minimap_until: None,
|
||||||
color_scheme: ColorScheme::default(),
|
color_scheme: ColorScheme::default(),
|
||||||
hue_rotation: 0.0,
|
hue_rotation: 0.0,
|
||||||
|
effects: RefCell::new(EffectManager::default()),
|
||||||
|
modal_fx: RefCell::new(None),
|
||||||
|
title_fx: RefCell::new(Some(fx::coalesce((400, Interpolation::QuadOut)))),
|
||||||
|
prev_modal_open: false,
|
||||||
|
prev_page: Page::default(),
|
||||||
|
prev_show_title: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
// Staged mute/solo (will toggle on commit)
|
// Staged mute/solo (will toggle on commit)
|
||||||
let has_staged_mute = app.playback.has_staged_mute(bank, idx);
|
let has_staged_mute = app.playback.has_staged_mute(bank, idx);
|
||||||
let has_staged_solo = app.playback.has_staged_solo(bank, idx);
|
let has_staged_solo = app.playback.has_staged_solo(bank, idx);
|
||||||
|
let has_staged_props = app.playback.has_staged_props(bank, idx);
|
||||||
|
|
||||||
// Preview state (what it will be after commit)
|
// Preview state (what it will be after commit)
|
||||||
let preview_muted = is_muted ^ has_staged_mute;
|
let preview_muted = is_muted ^ has_staged_mute;
|
||||||
@@ -386,13 +387,14 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
spans.push(Span::styled(format!(" {name}"), name_style));
|
spans.push(Span::styled(format!(" {name}"), name_style));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right-aligned info: length and speed
|
// Right-aligned info: length, speed, and staged props indicator
|
||||||
let speed_str = if speed != PatternSpeed::NORMAL {
|
let speed_str = if speed != PatternSpeed::NORMAL {
|
||||||
format!(" {}", speed.label())
|
format!(" {}", speed.label())
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
let right_info = format!("{length}{speed_str}");
|
let props_indicator = if has_staged_props { "~" } else { "" };
|
||||||
|
let right_info = format!("{props_indicator}{length}{speed_str}");
|
||||||
let left_width: usize = spans.iter().map(|s| s.content.chars().count()).sum();
|
let left_width: usize = spans.iter().map(|s| s.content.chars().count()).sum();
|
||||||
let right_width = right_info.chars().count();
|
let right_width = right_info.chars().count();
|
||||||
let padding = (text_area.width as usize).saturating_sub(left_width + right_width + 1);
|
let padding = (text_area.width as usize).saturating_sub(left_width + right_width + 1);
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ use std::collections::hash_map::DefaultHasher;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use rand::rngs::StdRng;
|
use rand::rngs::StdRng;
|
||||||
use rand::SeedableRng;
|
use rand::SeedableRng;
|
||||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table};
|
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
@@ -150,7 +150,7 @@ fn adjust_spans_for_line(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &SequencerSnapshot) {
|
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &SequencerSnapshot, elapsed: Duration) {
|
||||||
let term = frame.area();
|
let term = frame.area();
|
||||||
|
|
||||||
let theme = theme::get();
|
let theme = theme::get();
|
||||||
@@ -165,6 +165,14 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc
|
|||||||
|
|
||||||
if app.ui.show_title {
|
if app.ui.show_title {
|
||||||
title_view::render(frame, term, &app.ui);
|
title_view::render(frame, term, &app.ui);
|
||||||
|
|
||||||
|
let mut fx = app.ui.title_fx.borrow_mut();
|
||||||
|
if let Some(effect) = fx.as_mut() {
|
||||||
|
effect.process(elapsed, frame.buffer_mut(), term);
|
||||||
|
if !effect.running() {
|
||||||
|
*fx = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +226,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc
|
|||||||
}
|
}
|
||||||
|
|
||||||
render_footer(frame, app, footer_area);
|
render_footer(frame, app, footer_area);
|
||||||
render_modal(frame, app, snapshot, term);
|
let modal_area = render_modal(frame, app, snapshot, term);
|
||||||
|
|
||||||
let show_minimap = app
|
let show_minimap = app
|
||||||
.ui
|
.ui
|
||||||
@@ -241,6 +249,21 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc
|
|||||||
let selected = app.page.grid_pos();
|
let selected = app.page.grid_pos();
|
||||||
NavMinimap::new(&tiles, selected).render_centered(frame, term);
|
NavMinimap::new(&tiles, selected).render_centered(frame, term);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.ui
|
||||||
|
.effects
|
||||||
|
.borrow_mut()
|
||||||
|
.process_effects(elapsed, frame.buffer_mut(), term);
|
||||||
|
|
||||||
|
if let Some(area) = modal_area {
|
||||||
|
let mut fx = app.ui.modal_fx.borrow_mut();
|
||||||
|
if let Some(effect) = fx.as_mut() {
|
||||||
|
effect.process(elapsed, frame.buffer_mut(), area);
|
||||||
|
if !effect.running() {
|
||||||
|
*fx = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn header_height(width: u16) -> u16 {
|
fn header_height(width: u16) -> u16 {
|
||||||
@@ -529,37 +552,35 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
frame.render_widget(footer, area);
|
frame.render_widget(footer, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term: Rect) {
|
fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term: Rect) -> Option<Rect> {
|
||||||
let theme = theme::get();
|
let theme = theme::get();
|
||||||
match &app.ui.modal {
|
let inner = match &app.ui.modal {
|
||||||
Modal::None => {}
|
Modal::None => return None,
|
||||||
Modal::ConfirmQuit { selected } => {
|
Modal::ConfirmQuit { selected } => {
|
||||||
ConfirmModal::new("Confirm", "Quit?", *selected).render_centered(frame, term);
|
ConfirmModal::new("Confirm", "Quit?", *selected).render_centered(frame, term)
|
||||||
}
|
}
|
||||||
Modal::ConfirmDeleteStep { step, selected, .. } => {
|
Modal::ConfirmDeleteStep { step, selected, .. } => {
|
||||||
ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected)
|
ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected)
|
||||||
.render_centered(frame, term);
|
.render_centered(frame, term)
|
||||||
}
|
}
|
||||||
Modal::ConfirmDeleteSteps {
|
Modal::ConfirmDeleteSteps {
|
||||||
steps, selected, ..
|
steps, selected, ..
|
||||||
} => {
|
} => {
|
||||||
let nums: Vec<String> = steps.iter().map(|s| format!("{:02}", s + 1)).collect();
|
let nums: Vec<String> = steps.iter().map(|s| format!("{:02}", s + 1)).collect();
|
||||||
let label = format!("Delete steps {}?", nums.join(", "));
|
let label = format!("Delete steps {}?", nums.join(", "));
|
||||||
ConfirmModal::new("Confirm", &label, *selected).render_centered(frame, term);
|
ConfirmModal::new("Confirm", &label, *selected).render_centered(frame, term)
|
||||||
}
|
}
|
||||||
Modal::ConfirmResetPattern {
|
Modal::ConfirmResetPattern {
|
||||||
pattern, selected, ..
|
pattern, selected, ..
|
||||||
} => {
|
} => ConfirmModal::new(
|
||||||
ConfirmModal::new(
|
"Confirm",
|
||||||
"Confirm",
|
&format!("Reset pattern {}?", pattern + 1),
|
||||||
&format!("Reset pattern {}?", pattern + 1),
|
*selected,
|
||||||
*selected,
|
)
|
||||||
)
|
.render_centered(frame, term),
|
||||||
.render_centered(frame, term);
|
|
||||||
}
|
|
||||||
Modal::ConfirmResetBank { bank, selected } => {
|
Modal::ConfirmResetBank { bank, selected } => {
|
||||||
ConfirmModal::new("Confirm", &format!("Reset bank {}?", bank + 1), *selected)
|
ConfirmModal::new("Confirm", &format!("Reset bank {}?", bank + 1), *selected)
|
||||||
.render_centered(frame, term);
|
.render_centered(frame, term)
|
||||||
}
|
}
|
||||||
Modal::FileBrowser(state) => {
|
Modal::FileBrowser(state) => {
|
||||||
use crate::state::file_browser::FileBrowserMode;
|
use crate::state::file_browser::FileBrowserMode;
|
||||||
@@ -579,32 +600,30 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
|||||||
.border_color(border_color)
|
.border_color(border_color)
|
||||||
.width(60)
|
.width(60)
|
||||||
.height(18)
|
.height(18)
|
||||||
.render_centered(frame, term);
|
.render_centered(frame, term)
|
||||||
}
|
}
|
||||||
Modal::RenameBank { bank, name } => {
|
Modal::RenameBank { bank, name } => {
|
||||||
TextInputModal::new(&format!("Rename Bank {:02}", bank + 1), name)
|
TextInputModal::new(&format!("Rename Bank {:02}", bank + 1), name)
|
||||||
.width(40)
|
.width(40)
|
||||||
.border_color(theme.modal.rename)
|
.border_color(theme.modal.rename)
|
||||||
.render_centered(frame, term);
|
.render_centered(frame, term)
|
||||||
}
|
}
|
||||||
Modal::RenamePattern {
|
Modal::RenamePattern {
|
||||||
bank,
|
bank,
|
||||||
pattern,
|
pattern,
|
||||||
name,
|
name,
|
||||||
} => {
|
} => TextInputModal::new(
|
||||||
TextInputModal::new(
|
&format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1),
|
||||||
&format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1),
|
name,
|
||||||
name,
|
)
|
||||||
)
|
.width(40)
|
||||||
.width(40)
|
.border_color(theme.modal.rename)
|
||||||
.border_color(theme.modal.rename)
|
.render_centered(frame, term),
|
||||||
.render_centered(frame, term);
|
|
||||||
}
|
|
||||||
Modal::RenameStep { step, name, .. } => {
|
Modal::RenameStep { step, name, .. } => {
|
||||||
TextInputModal::new(&format!("Name Step {:02}", step + 1), name)
|
TextInputModal::new(&format!("Name Step {:02}", step + 1), name)
|
||||||
.width(40)
|
.width(40)
|
||||||
.border_color(theme.modal.input)
|
.border_color(theme.modal.input)
|
||||||
.render_centered(frame, term);
|
.render_centered(frame, term)
|
||||||
}
|
}
|
||||||
Modal::SetPattern { field, input } => {
|
Modal::SetPattern { field, input } => {
|
||||||
let (title, hint) = match field {
|
let (title, hint) = match field {
|
||||||
@@ -615,15 +634,13 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
|||||||
.hint(hint)
|
.hint(hint)
|
||||||
.width(45)
|
.width(45)
|
||||||
.border_color(theme.modal.confirm)
|
.border_color(theme.modal.confirm)
|
||||||
.render_centered(frame, term);
|
.render_centered(frame, term)
|
||||||
}
|
|
||||||
Modal::SetTempo(input) => {
|
|
||||||
TextInputModal::new("Set Tempo (20-300 BPM)", input)
|
|
||||||
.hint("Enter BPM")
|
|
||||||
.width(30)
|
|
||||||
.border_color(theme.modal.rename)
|
|
||||||
.render_centered(frame, term);
|
|
||||||
}
|
}
|
||||||
|
Modal::SetTempo(input) => TextInputModal::new("Set Tempo (20-300 BPM)", input)
|
||||||
|
.hint("Enter BPM")
|
||||||
|
.width(30)
|
||||||
|
.border_color(theme.modal.rename)
|
||||||
|
.render_centered(frame, term),
|
||||||
Modal::AddSamplePath(state) => {
|
Modal::AddSamplePath(state) => {
|
||||||
use crate::widgets::FileBrowserModal;
|
use crate::widgets::FileBrowserModal;
|
||||||
let entries: Vec<(String, bool, bool)> = state
|
let entries: Vec<(String, bool, bool)> = state
|
||||||
@@ -637,7 +654,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
|||||||
.border_color(theme.modal.rename)
|
.border_color(theme.modal.rename)
|
||||||
.width(60)
|
.width(60)
|
||||||
.height(18)
|
.height(18)
|
||||||
.render_centered(frame, term);
|
.render_centered(frame, term)
|
||||||
}
|
}
|
||||||
Modal::Preview => {
|
Modal::Preview => {
|
||||||
let width = (term.width * 80 / 100).max(40);
|
let width = (term.width * 80 / 100).max(40);
|
||||||
@@ -714,6 +731,8 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
|||||||
let paragraph = Paragraph::new(lines);
|
let paragraph = Paragraph::new(lines);
|
||||||
frame.render_widget(paragraph, inner);
|
frame.render_widget(paragraph, inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inner
|
||||||
}
|
}
|
||||||
Modal::Editor => {
|
Modal::Editor => {
|
||||||
let width = (term.width * 80 / 100).max(40);
|
let width = (term.width * 80 / 100).max(40);
|
||||||
@@ -871,6 +890,8 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
|||||||
]);
|
]);
|
||||||
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area);
|
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inner
|
||||||
}
|
}
|
||||||
Modal::PatternProps {
|
Modal::PatternProps {
|
||||||
bank,
|
bank,
|
||||||
@@ -884,19 +905,12 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
|||||||
} => {
|
} => {
|
||||||
use crate::state::PatternPropsField;
|
use crate::state::PatternPropsField;
|
||||||
|
|
||||||
let width = 50u16;
|
let inner =
|
||||||
let height = 12u16;
|
ModalFrame::new(&format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1))
|
||||||
let x = (term.width.saturating_sub(width)) / 2;
|
.width(50)
|
||||||
let y = (term.height.saturating_sub(height)) / 2;
|
.height(12)
|
||||||
let area = Rect::new(x, y, width, height);
|
.border_color(theme.modal.input)
|
||||||
|
.render_centered(frame, term);
|
||||||
let block = Block::bordered()
|
|
||||||
.title(format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1))
|
|
||||||
.border_style(Style::default().fg(theme.modal.input));
|
|
||||||
|
|
||||||
let inner = block.inner(area);
|
|
||||||
frame.render_widget(Clear, area);
|
|
||||||
frame.render_widget(block, area);
|
|
||||||
|
|
||||||
let fields = [
|
let fields = [
|
||||||
("Name", name.as_str(), *field == PatternPropsField::Name),
|
("Name", name.as_str(), *field == PatternPropsField::Name),
|
||||||
@@ -962,6 +976,8 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
|||||||
Span::styled(" cancel", Style::default().fg(theme.hint.text)),
|
Span::styled(" cancel", Style::default().fg(theme.hint.text)),
|
||||||
]);
|
]);
|
||||||
frame.render_widget(Paragraph::new(hint_line), hint_area);
|
frame.render_widget(Paragraph::new(hint_line), hint_area);
|
||||||
|
|
||||||
|
inner
|
||||||
}
|
}
|
||||||
Modal::KeybindingsHelp { scroll } => {
|
Modal::KeybindingsHelp { scroll } => {
|
||||||
let width = (term.width * 80 / 100).clamp(60, 100);
|
let width = (term.width * 80 / 100).clamp(60, 100);
|
||||||
@@ -1033,6 +1049,8 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
|||||||
Paragraph::new(keybind_hint).alignment(Alignment::Right),
|
Paragraph::new(keybind_hint).alignment(Alignment::Right),
|
||||||
hint_area,
|
hint_area,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
inner
|
||||||
}
|
}
|
||||||
Modal::EuclideanDistribution {
|
Modal::EuclideanDistribution {
|
||||||
source_step,
|
source_step,
|
||||||
@@ -1042,30 +1060,14 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
|||||||
rotation,
|
rotation,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let width = 50u16;
|
let inner = ModalFrame::new(&format!(
|
||||||
let height = 11u16;
|
" Euclidean Distribution (Step {:02}) ",
|
||||||
let x = (term.width.saturating_sub(width)) / 2;
|
source_step + 1
|
||||||
let y = (term.height.saturating_sub(height)) / 2;
|
))
|
||||||
let area = Rect::new(x, y, width, height);
|
.width(50)
|
||||||
|
.height(11)
|
||||||
let block = Block::bordered()
|
.border_color(theme.modal.input)
|
||||||
.title(format!(" Euclidean Distribution (Step {:02}) ", source_step + 1))
|
.render_centered(frame, term);
|
||||||
.border_style(Style::default().fg(theme.modal.input));
|
|
||||||
|
|
||||||
let inner = block.inner(area);
|
|
||||||
frame.render_widget(Clear, area);
|
|
||||||
|
|
||||||
// Fill background with theme color
|
|
||||||
let bg_fill = " ".repeat(area.width as usize);
|
|
||||||
for row in 0..area.height {
|
|
||||||
let line_area = Rect::new(area.x, area.y + row, area.width, 1);
|
|
||||||
frame.render_widget(
|
|
||||||
Paragraph::new(bg_fill.clone()).style(Style::new().bg(theme.ui.bg)),
|
|
||||||
line_area,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
frame.render_widget(block, area);
|
|
||||||
|
|
||||||
let fields = [
|
let fields = [
|
||||||
(
|
(
|
||||||
@@ -1140,8 +1142,18 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
|||||||
Span::styled(" cancel", Style::default().fg(theme.hint.text)),
|
Span::styled(" cancel", Style::default().fg(theme.hint.text)),
|
||||||
]);
|
]);
|
||||||
frame.render_widget(Paragraph::new(hint_line), hint_area);
|
frame.render_widget(Paragraph::new(hint_line), hint_area);
|
||||||
|
|
||||||
|
inner
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// Expand inner rect to include the border
|
||||||
|
Some(Rect::new(
|
||||||
|
inner.x.saturating_sub(1),
|
||||||
|
inner.y.saturating_sub(1),
|
||||||
|
inner.width + 2,
|
||||||
|
inner.height + 2,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_euclidean_preview(pulses: usize, steps: usize, rotation: usize) -> String {
|
fn format_euclidean_preview(pulses: usize, steps: usize, rotation: usize) -> String {
|
||||||
|
|||||||
Reference in New Issue
Block a user