From 276107433a063fa064b9dd8799622a95da1d5d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Tue, 20 Jan 2026 03:30:48 +0100 Subject: [PATCH] Refactoring --- seq/src/app.rs | 72 +++++++-- seq/src/commands.rs | 2 +- seq/src/{ => engine}/audio.rs | 37 ++++- seq/src/{ => engine}/link.rs | 0 seq/src/engine/mod.rs | 10 ++ seq/src/{ => engine}/sequencer.rs | 5 +- seq/src/input.rs | 3 +- seq/src/main.rs | 62 ++------ seq/src/{ => model}/file.rs | 2 +- seq/src/model/mod.rs | 7 + seq/src/{model.rs => model/project.rs} | 6 +- seq/src/{ => model}/script.rs | 146 +++++++++++++---- seq/src/state/audio.rs | 4 + seq/src/state/playback.rs | 2 +- seq/src/views/main_view.rs | 28 +++- seq/src/views/mod.rs | 3 + seq/src/views/patterns_view.rs | 2 +- seq/src/{ui.rs => views/render.rs} | 210 +++++-------------------- seq/src/widgets/confirm.rs | 60 +++++++ seq/src/widgets/mod.rs | 8 + seq/src/widgets/modal.rs | 58 +++++++ seq/src/widgets/scope.rs | 4 +- seq/src/widgets/text_input.rs | 82 ++++++++++ seq/src/widgets/vu_meter.rs | 67 ++++++++ 24 files changed, 582 insertions(+), 298 deletions(-) rename seq/src/{ => engine}/audio.rs (74%) rename seq/src/{ => engine}/link.rs (100%) create mode 100644 seq/src/engine/mod.rs rename seq/src/{ => engine}/sequencer.rs (98%) rename seq/src/{ => model}/file.rs (97%) create mode 100644 seq/src/model/mod.rs rename seq/src/{model.rs => model/project.rs} (98%) rename seq/src/{ => model}/script.rs (67%) rename seq/src/{ui.rs => views/render.rs} (50%) create mode 100644 seq/src/widgets/confirm.rs create mode 100644 seq/src/widgets/modal.rs create mode 100644 seq/src/widgets/text_input.rs create mode 100644 seq/src/widgets/vu_meter.rs diff --git a/seq/src/app.rs b/seq/src/app.rs index c1bfe4d..eb70e9d 100644 --- a/seq/src/app.rs +++ b/seq/src/app.rs @@ -4,14 +4,15 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; +use crossbeam_channel::Sender; + use crate::commands::AppCommand; use crate::config::MAX_SLOTS; -use crate::file; -use crate::link::LinkState; -use crate::model::Pattern; +use crate::engine::{ + LinkState, PatternSnapshot, SeqCommand, SequencerSnapshot, SlotChange, StepSnapshot, +}; +use crate::model::{self, Pattern, Rng, ScriptEngine, StepContext, Variables}; use crate::page::Page; -use crate::script::{Rng, ScriptEngine, StepContext, Variables}; -use crate::sequencer::{SequencerSnapshot, SlotChange}; use crate::services::pattern_editor; use crate::state::{ AudioSettings, EditorContext, Focus, Metrics, Modal, PatternField, PatternsViewLevel, @@ -334,14 +335,14 @@ impl App { pattern: usize, snapshot: &SequencerSnapshot, ) -> Option { - self.playback.queued_changes.iter().find_map(|c| match c { + self.playback.queued_changes.iter().find_map(|c| match *c { SlotChange::Add { slot: _, bank: b, pattern: p, - } if *b == bank && *p == pattern => Some(true), + } if b == bank && p == pattern => Some(true), SlotChange::Remove { slot } => { - let s = snapshot.slot_data[*slot]; + let s = snapshot.slot_data[slot]; if s.active && s.bank == bank && s.pattern == pattern { Some(false) } else { @@ -366,14 +367,14 @@ impl App { } }); - let pending = self.playback.queued_changes.iter().position(|c| match c { + let pending = self.playback.queued_changes.iter().position(|c| match *c { SlotChange::Add { bank: b, pattern: p, .. - } => *b == bank && *p == pattern, + } => b == bank && p == pattern, SlotChange::Remove { slot } => { - let s = snapshot.slot_data[*slot]; + let s = snapshot.slot_data[slot]; s.bank == bank && s.pattern == pattern } }); @@ -428,7 +429,7 @@ impl App { pub fn save(&mut self, path: PathBuf) { self.save_editor_to_step(); - match file::save(&self.project_state.project, &path) { + match model::save(&self.project_state.project, &path) { Ok(()) => { self.ui.set_status(format!("Saved: {}", path.display())); self.project_state.file_path = Some(path); @@ -440,7 +441,7 @@ impl App { } pub fn load(&mut self, path: PathBuf, link: &LinkState) { - match file::load(&path) { + match model::load(&path) { Ok(project) => { self.project_state.project = project; self.editor_ctx.step = 0; @@ -663,4 +664,49 @@ impl App { }, } } + + pub fn flush_queued_changes(&mut self, cmd_tx: &Sender) { + for change in self.playback.queued_changes.drain(..) { + match change { + SlotChange::Add { + slot, + bank, + pattern, + } => { + let _ = cmd_tx.send(SeqCommand::SlotAdd { + slot, + bank, + pattern, + }); + } + SlotChange::Remove { slot } => { + let _ = cmd_tx.send(SeqCommand::SlotRemove { slot }); + } + } + } + } + + pub fn flush_dirty_patterns(&mut self, cmd_tx: &Sender) { + for (bank, pattern) in self.project_state.take_dirty() { + let pat = self.project_state.project.pattern_at(bank, pattern); + let snapshot = PatternSnapshot { + speed: pat.speed, + length: pat.length, + steps: pat + .steps + .iter() + .take(pat.length) + .map(|s| StepSnapshot { + active: s.active, + script: s.script.clone(), + }) + .collect(), + }; + let _ = cmd_tx.send(SeqCommand::PatternUpdate { + bank, + pattern, + data: snapshot, + }); + } + } } diff --git a/seq/src/commands.rs b/seq/src/commands.rs index 230e299..c4e9c03 100644 --- a/seq/src/commands.rs +++ b/seq/src/commands.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; +use crate::engine::SlotChange; use crate::model::PatternSpeed; -use crate::sequencer::SlotChange; use crate::state::{Modal, PatternField}; pub enum AppCommand { diff --git a/seq/src/audio.rs b/seq/src/engine/audio.rs similarity index 74% rename from seq/src/audio.rs rename to seq/src/engine/audio.rs index 945ff90..93fb393 100644 --- a/seq/src/audio.rs +++ b/seq/src/engine/audio.rs @@ -5,29 +5,49 @@ use doux::{Engine, EngineMetrics}; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; -use crate::sequencer::AudioCommand; +use super::AudioCommand; pub struct ScopeBuffer { pub samples: [AtomicU32; 64], + peak_left: AtomicU32, + peak_right: AtomicU32, } impl ScopeBuffer { pub fn new() -> Self { Self { samples: std::array::from_fn(|_| AtomicU32::new(0)), + peak_left: AtomicU32::new(0), + peak_right: AtomicU32::new(0), } } pub fn write(&self, data: &[f32]) { + let mut peak_l: f32 = 0.0; + let mut peak_r: f32 = 0.0; + for (i, atom) in self.samples.iter().enumerate() { - let val = data.get(i * 2).copied().unwrap_or(0.0); - atom.store(val.to_bits(), Ordering::Relaxed); + let idx = i * 2; + let left = data.get(idx).copied().unwrap_or(0.0); + let right = data.get(idx + 1).copied().unwrap_or(0.0); + peak_l = peak_l.max(left.abs()); + peak_r = peak_r.max(right.abs()); + atom.store(left.to_bits(), Ordering::Relaxed); } + + self.peak_left.store(peak_l.to_bits(), Ordering::Relaxed); + self.peak_right.store(peak_r.to_bits(), Ordering::Relaxed); } pub fn read(&self) -> [f32; 64] { std::array::from_fn(|i| f32::from_bits(self.samples[i].load(Ordering::Relaxed))) } + + pub fn peaks(&self) -> (f32, f32) { + let left = f32::from_bits(self.peak_left.load(Ordering::Relaxed)); + let right = f32::from_bits(self.peak_right.load(Ordering::Relaxed)); + (left, right) + } } pub struct AudioStreamConfig { @@ -56,7 +76,9 @@ pub fn build_stream( let device = match &config.output_device { Some(name) => doux::audio::find_output_device(name) .ok_or_else(|| format!("Device not found: {name}"))?, - None => host.default_output_device().ok_or("No default output device")?, + None => host + .default_output_device() + .ok_or("No default output device")?, }; let default_config = device.default_output_config().map_err(|e| e.to_string())?; @@ -104,7 +126,8 @@ pub fn build_stream( } AudioCommand::ResetEngine => { let old_samples = std::mem::take(&mut engine.sample_index); - engine = Engine::new_with_metrics(sr, channels, Arc::clone(&metrics_clone)); + engine = + Engine::new_with_metrics(sr, channels, Arc::clone(&metrics_clone)); engine.sample_index = old_samples; } } @@ -119,6 +142,8 @@ pub fn build_stream( ) .map_err(|e| format!("Failed to build stream: {e}"))?; - stream.play().map_err(|e| format!("Failed to play stream: {e}"))?; + stream + .play() + .map_err(|e| format!("Failed to play stream: {e}"))?; Ok((stream, sample_rate)) } diff --git a/seq/src/link.rs b/seq/src/engine/link.rs similarity index 100% rename from seq/src/link.rs rename to seq/src/engine/link.rs diff --git a/seq/src/engine/mod.rs b/seq/src/engine/mod.rs new file mode 100644 index 0000000..b10fe2d --- /dev/null +++ b/seq/src/engine/mod.rs @@ -0,0 +1,10 @@ +mod audio; +mod link; +mod sequencer; + +pub use audio::{build_stream, AudioStreamConfig, PatternSlot, ScopeBuffer}; +pub use link::LinkState; +pub use sequencer::{ + spawn_sequencer, AudioCommand, PatternSnapshot, SeqCommand, SequencerSnapshot, SlotChange, + StepSnapshot, +}; diff --git a/seq/src/sequencer.rs b/seq/src/engine/sequencer.rs similarity index 98% rename from seq/src/sequencer.rs rename to seq/src/engine/sequencer.rs index 1c81ca0..ca2d6ae 100644 --- a/seq/src/sequencer.rs +++ b/seq/src/engine/sequencer.rs @@ -4,10 +4,9 @@ use std::sync::Arc; use std::thread::{self, JoinHandle}; use std::time::Duration; -use crate::audio::PatternSlot; +use super::{LinkState, PatternSlot}; use crate::config::{MAX_BANKS, MAX_PATTERNS, MAX_SLOTS}; -use crate::link::LinkState; -use crate::script::{Rng, ScriptEngine, StepContext, Variables}; +use crate::model::{Rng, ScriptEngine, StepContext, Variables}; #[derive(Clone, Copy, PartialEq, Eq)] pub enum SlotChange { diff --git a/seq/src/input.rs b/seq/src/input.rs index 7bc1921..0f38fee 100644 --- a/seq/src/input.rs +++ b/seq/src/input.rs @@ -6,10 +6,9 @@ use std::sync::Arc; use crate::app::App; use crate::commands::AppCommand; -use crate::link::LinkState; +use crate::engine::{AudioCommand, LinkState, SequencerSnapshot}; use crate::model::PatternSpeed; use crate::page::Page; -use crate::sequencer::{AudioCommand, SequencerSnapshot}; use crate::state::{AudioFocus, Focus, Modal, PatternField, PatternsViewLevel}; pub enum InputResult { diff --git a/seq/src/main.rs b/seq/src/main.rs index 2fcf2f3..a65d64a 100644 --- a/seq/src/main.rs +++ b/seq/src/main.rs @@ -1,17 +1,12 @@ mod app; -mod audio; mod commands; mod config; -mod file; +mod engine; mod input; -mod link; mod model; mod page; -mod script; -mod sequencer; mod services; mod state; -mod ui; mod views; mod widgets; @@ -32,10 +27,8 @@ use ratatui::prelude::CrosstermBackend; use ratatui::Terminal; use app::App; -use audio::{AudioStreamConfig, ScopeBuffer}; +use engine::{build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer}; use input::{handle_key, InputContext, InputResult}; -use link::LinkState; -use sequencer::{spawn_sequencer, PatternSnapshot, SeqCommand, SlotChange, StepSnapshot}; #[derive(Parser)] #[command(name = "seq", about = "A step sequencer with Ableton Link support")] @@ -103,7 +96,7 @@ fn main() -> io::Result<()> { buffer_size: app.audio.config.buffer_size, }; - let (mut stream, sample_rate) = audio::build_stream( + let (mut stream, sample_rate) = build_stream( &stream_config, sequencer.audio_rx.clone(), Arc::clone(&scope_buffer), @@ -139,7 +132,7 @@ fn main() -> io::Result<()> { } app.audio.config.sample_count = restart_samples.len(); - match audio::build_stream( + match build_stream( &new_config, sequencer.audio_rx.clone(), Arc::clone(&scope_buffer), @@ -158,7 +151,7 @@ fn main() -> io::Result<()> { let index = doux::loader::scan_samples_dir(path); fallback_samples.extend(index); } - let (fallback_stream, _) = audio::build_stream( + let (fallback_stream, _) = build_stream( &AudioStreamConfig { output_device: None, channels: 2, @@ -183,53 +176,16 @@ fn main() -> io::Result<()> { app.metrics.cpu_load = metrics.load.get_load(); app.metrics.schedule_depth = metrics.schedule_depth.load(Ordering::Relaxed) as usize; app.metrics.scope = scope_buffer.read(); + (app.metrics.peak_left, app.metrics.peak_right) = scope_buffer.peaks(); } let seq_snapshot = sequencer.snapshot(); app.metrics.event_count = seq_snapshot.event_count; - for change in app.playback.queued_changes.drain(..) { - match change { - SlotChange::Add { - slot, - bank, - pattern, - } => { - let _ = sequencer.cmd_tx.send(SeqCommand::SlotAdd { - slot, - bank, - pattern, - }); - } - SlotChange::Remove { slot } => { - let _ = sequencer.cmd_tx.send(SeqCommand::SlotRemove { slot }); - } - } - } + app.flush_queued_changes(&sequencer.cmd_tx); + app.flush_dirty_patterns(&sequencer.cmd_tx); - for (bank, pattern) in app.project_state.take_dirty() { - let pat = app.project_state.project.pattern_at(bank, pattern); - let snapshot = PatternSnapshot { - speed: pat.speed, - length: pat.length, - steps: pat - .steps - .iter() - .take(pat.length) - .map(|s| StepSnapshot { - active: s.active, - script: s.script.clone(), - }) - .collect(), - }; - let _ = sequencer.cmd_tx.send(SeqCommand::PatternUpdate { - bank, - pattern, - data: snapshot, - }); - } - - terminal.draw(|frame| ui::render(frame, &mut app, &link, &seq_snapshot))?; + terminal.draw(|frame| views::render(frame, &mut app, &link, &seq_snapshot))?; if event::poll(Duration::from_millis(16))? { if let Event::Key(key) = event::read()? { diff --git a/seq/src/file.rs b/seq/src/model/file.rs similarity index 97% rename from seq/src/file.rs rename to seq/src/model/file.rs index 00556c5..7ce74e6 100644 --- a/seq/src/file.rs +++ b/seq/src/model/file.rs @@ -4,7 +4,7 @@ use std::path::Path; use serde::{Deserialize, Serialize}; -use crate::model::{Bank, Project}; +use super::{Bank, Project}; const VERSION: u8 = 1; diff --git a/seq/src/model/mod.rs b/seq/src/model/mod.rs new file mode 100644 index 0000000..de607ee --- /dev/null +++ b/seq/src/model/mod.rs @@ -0,0 +1,7 @@ +mod file; +mod project; +mod script; + +pub use file::{load, save}; +pub use project::{Bank, Pattern, PatternSpeed, Project}; +pub use script::{Rng, ScriptEngine, StepContext, Variables}; diff --git a/seq/src/model.rs b/seq/src/model/project.rs similarity index 98% rename from seq/src/model.rs rename to seq/src/model/project.rs index 296741c..6d43351 100644 --- a/seq/src/model.rs +++ b/seq/src/model/project.rs @@ -9,9 +9,9 @@ pub enum PatternSpeed { Half, // 1/2x #[default] Normal, // 1x - Double, // 2x - Quad, // 4x - Octo, // 8x + Double, // 2x + Quad, // 4x + Octo, // 8x } impl PatternSpeed { diff --git a/seq/src/script.rs b/seq/src/model/script.rs similarity index 67% rename from seq/src/script.rs rename to seq/src/model/script.rs index aa771ac..b4563e4 100644 --- a/seq/src/script.rs +++ b/seq/src/model/script.rs @@ -43,11 +43,7 @@ impl Cmd { impl std::fmt::Display for Cmd { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let parts: Vec = self - .pairs - .iter() - .map(|(k, v)| format!("{k}/{v}")) - .collect(); + let parts: Vec = self.pairs.iter().map(|(k, v)| format!("{k}/{v}")).collect(); write!(f, "/{}", parts.join("/")) } } @@ -77,10 +73,7 @@ impl ScriptEngine { let vars_for_get = Arc::clone(&vars); engine.register_fn("set", move |name: &str, value: Dynamic| { - vars_for_set - .lock() - .unwrap() - .insert(name.to_string(), value); + vars_for_set.lock().unwrap().insert(name.to_string(), value); }); engine.register_fn("get", move |name: &str| -> Dynamic { @@ -102,11 +95,17 @@ impl ScriptEngine { rng_rand_ff.lock().unwrap().gen_range(min..max) }); engine.register_fn("rand", move |min: i64, max: i64| -> f64 { - rng_rand_ii.lock().unwrap().gen_range(min as f64..max as f64) + rng_rand_ii + .lock() + .unwrap() + .gen_range(min as f64..max as f64) }); engine.register_fn("rrand", move |min: f64, max: f64| -> i64 { - rng_rrand_ff.lock().unwrap().gen_range(min as i64..=max as i64) + rng_rrand_ff + .lock() + .unwrap() + .gen_range(min as i64..=max as i64) }); engine.register_fn("rrand", move |min: i64, max: i64| -> i64 { rng_rrand_ii.lock().unwrap().gen_range(min..=max) @@ -162,28 +161,111 @@ fn register_cmd(engine: &mut Engine) { } reg_both!( - "time", "repeat", "dur", "gate", - "freq", "detune", "speed", "glide", - "pw", "spread", "mult", "warp", "mirror", "harmonics", "timbre", "morph", "begin", "end", - "gain", "postgain", "velocity", "pan", - "attack", "decay", "sustain", "release", - "lpf", "lpq", "lpe", "lpa", "lpd", "lps", "lpr", - "hpf", "hpq", "hpe", "hpa", "hpd", "hps", "hpr", - "bpf", "bpq", "bpe", "bpa", "bpd", "bps", "bpr", + "time", + "repeat", + "dur", + "gate", + "freq", + "detune", + "speed", + "glide", + "pw", + "spread", + "mult", + "warp", + "mirror", + "harmonics", + "timbre", + "morph", + "begin", + "end", + "gain", + "postgain", + "velocity", + "pan", + "attack", + "decay", + "sustain", + "release", + "lpf", + "lpq", + "lpe", + "lpa", + "lpd", + "lps", + "lpr", + "hpf", + "hpq", + "hpe", + "hpa", + "hpd", + "hps", + "hpr", + "bpf", + "bpq", + "bpe", + "bpa", + "bpd", + "bps", + "bpr", "ftype", - "penv", "patt", "pdec", "psus", "prel", - "vib", "vibmod", "vibshape", - "fm", "fmh", "fmshape", "fme", "fma", "fmd", "fms", "fmr", - "am", "amdepth", "amshape", - "rm", "rmdepth", "rmshape", - "phaser", "phaserdepth", "phasersweep", "phasercenter", - "flanger", "flangerdepth", "flangerfeedback", - "chorus", "chorusdepth", "chorusdelay", - "comb", "combfreq", "combfeedback", "combdamp", - "coarse", "crush", "fold", "wrap", "distort", "distortvol", - "delay", "delaytime", "delayfeedback", "delaytype", - "verb", "verbdecay", "verbdamp", "verbpredelay", "verbdiff", - "voice", "orbit", "note", "size", "n", "cut" + "penv", + "patt", + "pdec", + "psus", + "prel", + "vib", + "vibmod", + "vibshape", + "fm", + "fmh", + "fmshape", + "fme", + "fma", + "fmd", + "fms", + "fmr", + "am", + "amdepth", + "amshape", + "rm", + "rmdepth", + "rmshape", + "phaser", + "phaserdepth", + "phasersweep", + "phasercenter", + "flanger", + "flangerdepth", + "flangerfeedback", + "chorus", + "chorusdepth", + "chorusdelay", + "comb", + "combfreq", + "combfeedback", + "combdamp", + "coarse", + "crush", + "fold", + "wrap", + "distort", + "distortvol", + "delay", + "delaytime", + "delayfeedback", + "delaytype", + "verb", + "verbdecay", + "verbdamp", + "verbpredelay", + "verbdiff", + "voice", + "orbit", + "note", + "size", + "n", + "cut" ); engine.register_fn("reset", |c: &mut Cmd, v: bool| { diff --git a/seq/src/state/audio.rs b/seq/src/state/audio.rs index 61eae34..a331847 100644 --- a/seq/src/state/audio.rs +++ b/seq/src/state/audio.rs @@ -43,6 +43,8 @@ pub struct Metrics { pub cpu_load: f32, pub schedule_depth: usize, pub scope: [f32; 64], + pub peak_left: f32, + pub peak_right: f32, } impl Default for Metrics { @@ -54,6 +56,8 @@ impl Default for Metrics { cpu_load: 0.0, schedule_depth: 0, scope: [0.0; 64], + peak_left: 0.0, + peak_right: 0.0, } } } diff --git a/seq/src/state/playback.rs b/seq/src/state/playback.rs index 92f3768..6113331 100644 --- a/seq/src/state/playback.rs +++ b/seq/src/state/playback.rs @@ -1,4 +1,4 @@ -use crate::sequencer::SlotChange; +use crate::engine::SlotChange; pub struct PlaybackState { pub playing: bool, diff --git a/seq/src/views/main_view.rs b/seq/src/views/main_view.rs index 717fe99..a65606a 100644 --- a/seq/src/views/main_view.rs +++ b/seq/src/views/main_view.rs @@ -4,13 +4,17 @@ use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; use crate::app::App; -use crate::sequencer::SequencerSnapshot; +use crate::engine::SequencerSnapshot; use crate::state::Focus; -use crate::widgets::{Orientation, Scope}; +use crate::widgets::{Orientation, Scope, VuMeter}; pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, area: Rect) { - let [main_area, scope_area] = - Layout::horizontal([Constraint::Fill(1), Constraint::Length(10)]).areas(area); + let [main_area, scope_area, vu_area] = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(10), + Constraint::Length(10), + ]) + .areas(area); let [seq_area, editor_area] = Layout::vertical([Constraint::Fill(3), Constraint::Fill(2)]).areas(main_area); @@ -18,6 +22,7 @@ pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, ar render_sequencer(frame, app, snapshot, seq_area); render_editor(frame, app, editor_area); render_scope(frame, app, scope_area); + render_vu_meter(frame, app, vu_area); } fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { @@ -186,8 +191,7 @@ fn render_editor(frame: &mut Frame, app: &mut App, area: Rect) { fn render_scope(frame: &mut Frame, app: &App, area: Rect) { let block = Block::default() .borders(Borders::ALL) - .border_style(Style::new().fg(Color::Rgb(70, 75, 85))) - .title("Scope"); + .border_style(Style::new().fg(Color::Rgb(70, 75, 85))); let inner = block.inner(area); frame.render_widget(block, area); @@ -197,3 +201,15 @@ fn render_scope(frame: &mut Frame, app: &App, area: Rect) { .color(Color::Green); frame.render_widget(scope, inner); } + +fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::new().fg(Color::Rgb(70, 75, 85))); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right); + frame.render_widget(vu, inner); +} diff --git a/seq/src/views/mod.rs b/seq/src/views/mod.rs index 94dd102..0ce84b9 100644 --- a/seq/src/views/mod.rs +++ b/seq/src/views/mod.rs @@ -2,3 +2,6 @@ pub mod audio_view; pub mod doc_view; pub mod main_view; pub mod patterns_view; +mod render; + +pub use render::render; diff --git a/seq/src/views/patterns_view.rs b/seq/src/views/patterns_view.rs index c0fb724..319f42d 100644 --- a/seq/src/views/patterns_view.rs +++ b/seq/src/views/patterns_view.rs @@ -5,7 +5,7 @@ use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; use crate::app::App; -use crate::sequencer::SequencerSnapshot; +use crate::engine::SequencerSnapshot; use crate::state::PatternsViewLevel; pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { diff --git a/seq/src/ui.rs b/seq/src/views/render.rs similarity index 50% rename from seq/src/ui.rs rename to seq/src/views/render.rs index 7be01b1..9b404f3 100644 --- a/seq/src/ui.rs +++ b/seq/src/views/render.rs @@ -1,15 +1,16 @@ use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; use crate::app::App; -use crate::link::LinkState; +use crate::engine::{LinkState, SequencerSnapshot}; use crate::page::Page; -use crate::sequencer::SequencerSnapshot; use crate::state::{Modal, PatternField}; -use crate::views::{audio_view, doc_view, main_view, patterns_view}; +use crate::widgets::{ConfirmModal, TextInputModal}; + +use super::{audio_view, doc_view, main_view, patterns_view}; pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &SequencerSnapshot) { let [header_area, body_area, footer_area] = Layout::vertical([ @@ -189,202 +190,61 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(footer, area); } -fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { - let x = area.x + area.width.saturating_sub(width) / 2; - let y = area.y + area.height.saturating_sub(height) / 2; - Rect::new(x, y, width.min(area.width), height.min(area.height)) -} - fn render_modal(frame: &mut Frame, app: &App) { let term = frame.area(); match &app.ui.modal { Modal::None => {} Modal::ConfirmQuit { selected } => { - let width = 30.min(term.width.saturating_sub(4)); - let height = 5.min(term.height.saturating_sub(4)); - let area = centered_rect(width, height, term); - frame.render_widget(Clear, area); - - let block = Block::default() - .borders(Borders::ALL) - .title("Confirm") - .border_style(Style::new().fg(Color::Yellow)); - let inner = block.inner(area); - frame.render_widget(block, area); - - let rows = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner); - - frame.render_widget( - Paragraph::new("Quit?").alignment(Alignment::Center), - rows[0], - ); - - let yes_style = if *selected { - Style::new().fg(Color::Black).bg(Color::Yellow) - } else { - Style::default() - }; - let no_style = if !*selected { - Style::new().fg(Color::Black).bg(Color::Yellow) - } else { - Style::default() - }; - - let buttons = Line::from(vec![ - Span::styled(" Yes ", yes_style), - Span::raw(" "), - Span::styled(" No ", no_style), - ]); - frame.render_widget( - Paragraph::new(buttons).alignment(Alignment::Center), - rows[1], - ); + ConfirmModal::new("Confirm", "Quit?", *selected).render_centered(frame, term); } Modal::SaveAs(path) => { - let width = (term.width * 60 / 100) - .clamp(40, 70) - .min(term.width.saturating_sub(4)); - let height = 5.min(term.height.saturating_sub(4)); - let area = centered_rect(width, height, term); - frame.render_widget(Clear, area); - let modal = Paragraph::new(Line::from(vec![ - Span::raw("> "), - Span::styled(path, Style::new().fg(Color::Cyan)), - Span::styled("█", Style::new().fg(Color::White)), - ])) - .block( - Block::default() - .borders(Borders::ALL) - .title("Save As (Enter to confirm, Esc to cancel)") - .border_style(Style::new().fg(Color::Green)), - ); - frame.render_widget(modal, area); + TextInputModal::new("Save As (Enter to confirm, Esc to cancel)", path) + .width(60) + .border_color(Color::Green) + .render_centered(frame, term); } Modal::LoadFrom(path) => { - let width = (term.width * 60 / 100) - .clamp(40, 70) - .min(term.width.saturating_sub(4)); - let height = 5.min(term.height.saturating_sub(4)); - let area = centered_rect(width, height, term); - frame.render_widget(Clear, area); - let modal = Paragraph::new(Line::from(vec![ - Span::raw("> "), - Span::styled(path, Style::new().fg(Color::Cyan)), - Span::styled("█", Style::new().fg(Color::White)), - ])) - .block( - Block::default() - .borders(Borders::ALL) - .title("Load From (Enter to confirm, Esc to cancel)") - .border_style(Style::new().fg(Color::Blue)), - ); - frame.render_widget(modal, area); + TextInputModal::new("Load From (Enter to confirm, Esc to cancel)", path) + .width(60) + .border_color(Color::Blue) + .render_centered(frame, term); } Modal::RenameBank { bank, name } => { - let width = 40.min(term.width.saturating_sub(4)); - let height = 5.min(term.height.saturating_sub(4)); - let area = centered_rect(width, height, term); - frame.render_widget(Clear, area); - let modal = Paragraph::new(Line::from(vec![ - Span::raw("> "), - Span::styled(name, Style::new().fg(Color::Cyan)), - Span::styled("█", Style::new().fg(Color::White)), - ])) - .block( - Block::default() - .borders(Borders::ALL) - .title(format!("Rename Bank {:02}", bank + 1)) - .border_style(Style::new().fg(Color::Magenta)), - ); - frame.render_widget(modal, area); + TextInputModal::new(&format!("Rename Bank {:02}", bank + 1), name) + .width(40) + .border_color(Color::Magenta) + .render_centered(frame, term); } Modal::RenamePattern { bank, pattern, name, } => { - let width = 40.min(term.width.saturating_sub(4)); - let height = 5.min(term.height.saturating_sub(4)); - let area = centered_rect(width, height, term); - frame.render_widget(Clear, area); - let modal = Paragraph::new(Line::from(vec![ - Span::raw("> "), - Span::styled(name, Style::new().fg(Color::Cyan)), - Span::styled("█", Style::new().fg(Color::White)), - ])) - .block( - Block::default() - .borders(Borders::ALL) - .title(format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1)) - .border_style(Style::new().fg(Color::Magenta)), - ); - frame.render_widget(modal, area); + TextInputModal::new( + &format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1), + name, + ) + .width(40) + .border_color(Color::Magenta) + .render_centered(frame, term); } Modal::SetPattern { field, input } => { let (title, hint) = match field { PatternField::Length => ("Set Length (2-32)", "Enter number"), PatternField::Speed => ("Set Speed", "1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x"), }; - let width = 45.min(term.width.saturating_sub(4)); - let height = 6.min(term.height.saturating_sub(4)); - let area = centered_rect(width, height, term); - frame.render_widget(Clear, area); - - let block = Block::default() - .borders(Borders::ALL) - .title(title) - .border_style(Style::new().fg(Color::Yellow)); - let inner = block.inner(area); - frame.render_widget(block, area); - - let rows = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner); - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::raw("> "), - Span::styled(input, Style::new().fg(Color::Cyan)), - Span::styled("█", Style::new().fg(Color::White)), - ])), - rows[0], - ); - frame.render_widget( - Paragraph::new(Span::styled(hint, Style::new().fg(Color::DarkGray))), - rows[1], - ); + TextInputModal::new(title, input) + .hint(hint) + .width(45) + .border_color(Color::Yellow) + .render_centered(frame, term); } Modal::AddSamplePath(path) => { - let width = 60.min(term.width.saturating_sub(4)); - let height = 6.min(term.height.saturating_sub(4)); - let area = centered_rect(width, height, term); - frame.render_widget(Clear, area); - - let block = Block::default() - .borders(Borders::ALL) - .title("Add Sample Path") - .border_style(Style::new().fg(Color::Magenta)); - let inner = block.inner(area); - frame.render_widget(block, area); - - let rows = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner); - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::raw("> "), - Span::styled(path, Style::new().fg(Color::Cyan)), - Span::styled("█", Style::new().fg(Color::White)), - ])), - rows[0], - ); - frame.render_widget( - Paragraph::new(Span::styled( - "Enter directory path containing samples", - Style::new().fg(Color::DarkGray), - )), - rows[1], - ); + TextInputModal::new("Add Sample Path", path) + .hint("Enter directory path containing samples") + .width(60) + .border_color(Color::Magenta) + .render_centered(frame, term); } } } diff --git a/seq/src/widgets/confirm.rs b/seq/src/widgets/confirm.rs new file mode 100644 index 0000000..1332426 --- /dev/null +++ b/seq/src/widgets/confirm.rs @@ -0,0 +1,60 @@ +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use super::ModalFrame; + +pub struct ConfirmModal<'a> { + title: &'a str, + message: &'a str, + selected: bool, +} + +impl<'a> ConfirmModal<'a> { + pub fn new(title: &'a str, message: &'a str, selected: bool) -> Self { + Self { + title, + message, + selected, + } + } + + pub fn render_centered(self, frame: &mut Frame, term: Rect) { + let inner = ModalFrame::new(self.title) + .width(30) + .height(5) + .border_color(Color::Yellow) + .render_centered(frame, term); + + let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner); + + frame.render_widget( + Paragraph::new(self.message).alignment(Alignment::Center), + rows[0], + ); + + let yes_style = if self.selected { + Style::new().fg(Color::Black).bg(Color::Yellow) + } else { + Style::default() + }; + let no_style = if !self.selected { + Style::new().fg(Color::Black).bg(Color::Yellow) + } else { + Style::default() + }; + + let buttons = Line::from(vec![ + Span::styled(" Yes ", yes_style), + Span::raw(" "), + Span::styled(" No ", no_style), + ]); + + frame.render_widget( + Paragraph::new(buttons).alignment(Alignment::Center), + rows[1], + ); + } +} diff --git a/seq/src/widgets/mod.rs b/seq/src/widgets/mod.rs index 3c59832..977a199 100644 --- a/seq/src/widgets/mod.rs +++ b/seq/src/widgets/mod.rs @@ -1,3 +1,11 @@ +mod confirm; +mod modal; mod scope; +mod text_input; +mod vu_meter; +pub use confirm::ConfirmModal; +pub use modal::ModalFrame; pub use scope::{Orientation, Scope}; +pub use text_input::TextInputModal; +pub use vu_meter::VuMeter; diff --git a/seq/src/widgets/modal.rs b/seq/src/widgets/modal.rs new file mode 100644 index 0000000..f3443fa --- /dev/null +++ b/seq/src/widgets/modal.rs @@ -0,0 +1,58 @@ +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::widgets::{Block, Borders, Clear}; +use ratatui::Frame; + +pub struct ModalFrame<'a> { + title: &'a str, + width: u16, + height: u16, + border_color: Color, +} + +impl<'a> ModalFrame<'a> { + pub fn new(title: &'a str) -> Self { + Self { + title, + width: 40, + height: 5, + border_color: Color::White, + } + } + + pub fn width(mut self, w: u16) -> Self { + self.width = w; + self + } + + pub fn height(mut self, h: u16) -> Self { + self.height = h; + self + } + + pub fn border_color(mut self, c: Color) -> Self { + self.border_color = c; + self + } + + pub fn render_centered(&self, frame: &mut Frame, term: Rect) -> Rect { + let width = self.width.min(term.width.saturating_sub(4)); + let height = self.height.min(term.height.saturating_sub(4)); + + let x = term.x + (term.width.saturating_sub(width)) / 2; + let y = term.y + (term.height.saturating_sub(height)) / 2; + let area = Rect::new(x, y, width, height); + + frame.render_widget(Clear, area); + + let block = Block::default() + .borders(Borders::ALL) + .title(self.title) + .border_style(Style::new().fg(self.border_color)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + inner + } +} diff --git a/seq/src/widgets/scope.rs b/seq/src/widgets/scope.rs index d7fbcec..1bfc6dc 100644 --- a/seq/src/widgets/scope.rs +++ b/seq/src/widgets/scope.rs @@ -43,7 +43,9 @@ impl Widget for Scope<'_> { } match self.orientation { - Orientation::Horizontal => render_horizontal(self.data, area, buf, self.color, self.gain), + Orientation::Horizontal => { + render_horizontal(self.data, area, buf, self.color, self.gain) + } Orientation::Vertical => render_vertical(self.data, area, buf, self.color, self.gain), } } diff --git a/seq/src/widgets/text_input.rs b/seq/src/widgets/text_input.rs new file mode 100644 index 0000000..e39e6f3 --- /dev/null +++ b/seq/src/widgets/text_input.rs @@ -0,0 +1,82 @@ +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use super::ModalFrame; + +pub struct TextInputModal<'a> { + title: &'a str, + input: &'a str, + hint: Option<&'a str>, + border_color: Color, + width: u16, +} + +impl<'a> TextInputModal<'a> { + pub fn new(title: &'a str, input: &'a str) -> Self { + Self { + title, + input, + hint: None, + border_color: Color::White, + width: 50, + } + } + + pub fn hint(mut self, h: &'a str) -> Self { + self.hint = Some(h); + self + } + + pub fn border_color(mut self, c: Color) -> Self { + self.border_color = c; + self + } + + pub fn width(mut self, w: u16) -> Self { + self.width = w; + self + } + + pub fn render_centered(self, frame: &mut Frame, term: Rect) { + let height = if self.hint.is_some() { 6 } else { 5 }; + + let inner = ModalFrame::new(self.title) + .width(self.width) + .height(height) + .border_color(self.border_color) + .render_centered(frame, term); + + if self.hint.is_some() { + let rows = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw("> "), + Span::styled(self.input, Style::new().fg(Color::Cyan)), + Span::styled("█", Style::new().fg(Color::White)), + ])), + rows[0], + ); + + if let Some(hint) = self.hint { + frame.render_widget( + Paragraph::new(Span::styled(hint, Style::new().fg(Color::DarkGray))), + rows[1], + ); + } + } else { + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw("> "), + Span::styled(self.input, Style::new().fg(Color::Cyan)), + Span::styled("█", Style::new().fg(Color::White)), + ])), + inner, + ); + } + } +} diff --git a/seq/src/widgets/vu_meter.rs b/seq/src/widgets/vu_meter.rs new file mode 100644 index 0000000..4e55d29 --- /dev/null +++ b/seq/src/widgets/vu_meter.rs @@ -0,0 +1,67 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::widgets::Widget; + +pub struct VuMeter { + left: f32, + right: f32, +} + +impl VuMeter { + pub fn new(left: f32, right: f32) -> Self { + Self { left, right } + } + + fn level_to_color(level: f32) -> Color { + if level > 0.9 { + Color::Red + } else if level > 0.7 { + Color::Yellow + } else { + Color::Green + } + } +} + +impl Widget for VuMeter { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width < 2 || area.height == 0 { + return; + } + + let height = area.height as usize; + let left_col = area.x; + let right_col = area.x + area.width - 1; + + let left_level = (self.left.clamp(0.0, 1.0) * height as f32) as usize; + let right_level = (self.right.clamp(0.0, 1.0) * height as f32) as usize; + + for row in 0..height { + let y = area.y + area.height - 1 - row as u16; + let level_at_row = (row as f32 + 0.5) / height as f32; + let color = Self::level_to_color(level_at_row); + + if row < left_level { + buf[(left_col, y)].set_char('█').set_fg(color); + } else { + buf[(left_col, y)].set_char('░').set_fg(Color::DarkGray); + } + + if row < right_level { + buf[(right_col, y)].set_char('█').set_fg(color); + } else { + buf[(right_col, y)].set_char('░').set_fg(Color::DarkGray); + } + } + + if area.width > 2 { + for row in 0..height { + let y = area.y + row as u16; + for x in (area.x + 1)..(area.x + area.width - 1) { + buf[(x, y)].set_char(' '); + } + } + } + } +}