Refactoring
This commit is contained in:
@@ -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<bool> {
|
||||
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<SeqCommand>) {
|
||||
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<SeqCommand>) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
10
seq/src/engine/mod.rs
Normal file
10
seq/src/engine/mod.rs
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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 {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()? {
|
||||
|
||||
@@ -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;
|
||||
|
||||
7
seq/src/model/mod.rs
Normal file
7
seq/src/model/mod.rs
Normal file
@@ -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};
|
||||
@@ -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 {
|
||||
@@ -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<String> = self
|
||||
.pairs
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}/{v}"))
|
||||
.collect();
|
||||
let parts: Vec<String> = 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| {
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::sequencer::SlotChange;
|
||||
use crate::engine::SlotChange;
|
||||
|
||||
pub struct PlaybackState {
|
||||
pub playing: bool,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
seq/src/widgets/confirm.rs
Normal file
60
seq/src/widgets/confirm.rs
Normal file
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
58
seq/src/widgets/modal.rs
Normal file
58
seq/src/widgets/modal.rs
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
82
seq/src/widgets/text_input.rs
Normal file
82
seq/src/widgets/text_input.rs
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
seq/src/widgets/vu_meter.rs
Normal file
67
seq/src/widgets/vu_meter.rs
Normal file
@@ -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(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user