WIP: better precision?
This commit is contained in:
@@ -18,7 +18,7 @@ path = "src/main.rs"
|
||||
cagire-forth = { path = "crates/forth" }
|
||||
cagire-project = { path = "crates/project" }
|
||||
cagire-ratatui = { path = "crates/ratatui" }
|
||||
doux = { git = "https://github.com/Bubobubobubobubo/doux", features = ["native"] }
|
||||
doux = { git = "https://github.com/sova-org/doux", features = ["native"] }
|
||||
rusty_link = "0.4"
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
|
||||
@@ -91,6 +91,7 @@ impl App {
|
||||
channels: self.audio.config.channels,
|
||||
buffer_size: self.audio.config.buffer_size,
|
||||
max_voices: self.audio.config.max_voices,
|
||||
lookahead_ms: self.audio.config.lookahead_ms,
|
||||
},
|
||||
display: crate::settings::DisplaySettings {
|
||||
fps: self.audio.config.refresh_rate.to_fps(),
|
||||
|
||||
@@ -4,7 +4,7 @@ use crossbeam_channel::Receiver;
|
||||
use doux::{Engine, EngineMetrics};
|
||||
use ringbuf::{traits::*, HeapRb};
|
||||
use rustfft::{num_complex::Complex, FftPlanner};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
@@ -225,6 +225,7 @@ pub fn build_stream(
|
||||
spectrum_buffer: Arc<SpectrumBuffer>,
|
||||
metrics: Arc<EngineMetrics>,
|
||||
initial_samples: Vec<doux::sample::SampleEntry>,
|
||||
audio_sample_pos: Arc<AtomicU64>,
|
||||
) -> Result<(Stream, f32, AnalysisHandle), String> {
|
||||
let host = cpal::default_host();
|
||||
|
||||
@@ -270,8 +271,12 @@ pub fn build_stream(
|
||||
|
||||
while let Ok(cmd) = audio_rx.try_recv() {
|
||||
match cmd {
|
||||
AudioCommand::Evaluate(s) => {
|
||||
engine.evaluate(&s);
|
||||
AudioCommand::Evaluate { cmd, time } => {
|
||||
let cmd_with_time = match time {
|
||||
Some(t) => format!("{cmd}/time/{t:.6}"),
|
||||
None => cmd,
|
||||
};
|
||||
engine.evaluate(&cmd_with_time);
|
||||
}
|
||||
AudioCommand::Hush => {
|
||||
engine.hush();
|
||||
@@ -287,6 +292,7 @@ pub fn build_stream(
|
||||
engine =
|
||||
Engine::new_with_metrics(sr, channels, max_voices, Arc::clone(&metrics_clone));
|
||||
engine.sample_index = old_samples;
|
||||
audio_sample_pos.store(0, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,6 +301,8 @@ pub fn build_stream(
|
||||
engine.process_block(data, &[], &[]);
|
||||
scope_buffer.write(&engine.output);
|
||||
|
||||
audio_sample_pos.fetch_add(buffer_samples as u64, Ordering::Relaxed);
|
||||
|
||||
// Feed mono mix to analysis thread via ring buffer (non-blocking)
|
||||
for chunk in engine.output.chunks(channels) {
|
||||
let mono = chunk.iter().sum::<f32>() / channels as f32;
|
||||
|
||||
@@ -5,6 +5,6 @@ mod sequencer;
|
||||
pub use audio::{build_stream, AudioStreamConfig, ScopeBuffer, SpectrumBuffer};
|
||||
pub use link::LinkState;
|
||||
pub use sequencer::{
|
||||
spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand, SequencerSnapshot,
|
||||
StepSnapshot,
|
||||
spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand, SequencerConfig,
|
||||
SequencerSnapshot, StepSnapshot,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use arc_swap::ArcSwap;
|
||||
use crossbeam_channel::{bounded, Receiver, Sender, TrySendError};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicI64;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64};
|
||||
use std::sync::Arc;
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
@@ -40,7 +40,7 @@ impl PatternChange {
|
||||
}
|
||||
|
||||
pub enum AudioCommand {
|
||||
Evaluate(String),
|
||||
Evaluate { cmd: String, time: Option<f64> },
|
||||
Hush,
|
||||
Panic,
|
||||
LoadSamples(Vec<doux::sample::SampleEntry>),
|
||||
@@ -199,6 +199,12 @@ impl AudioState {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SequencerConfig {
|
||||
pub audio_sample_pos: Arc<AtomicU64>,
|
||||
pub sample_rate: Arc<std::sync::atomic::AtomicU32>,
|
||||
pub lookahead_ms: Arc<std::sync::atomic::AtomicU32>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spawn_sequencer(
|
||||
link: Arc<LinkState>,
|
||||
@@ -209,6 +215,7 @@ pub fn spawn_sequencer(
|
||||
quantum: f64,
|
||||
live_keys: Arc<LiveKeyState>,
|
||||
nudge_us: Arc<AtomicI64>,
|
||||
config: SequencerConfig,
|
||||
) -> (SequencerHandle, Receiver<AudioCommand>) {
|
||||
let (cmd_tx, cmd_rx) = bounded::<SeqCommand>(64);
|
||||
let (audio_tx, audio_rx) = bounded::<AudioCommand>(256);
|
||||
@@ -233,6 +240,9 @@ pub fn spawn_sequencer(
|
||||
shared_state_clone,
|
||||
live_keys,
|
||||
nudge_us,
|
||||
config.audio_sample_pos,
|
||||
config.sample_rate,
|
||||
config.lookahead_ms,
|
||||
);
|
||||
})
|
||||
.expect("Failed to spawn sequencer thread");
|
||||
@@ -359,10 +369,18 @@ pub(crate) struct TickInput {
|
||||
pub quantum: f64,
|
||||
pub fill: bool,
|
||||
pub nudge_secs: f64,
|
||||
pub current_time_us: i64,
|
||||
pub engine_time: f64,
|
||||
pub lookahead_secs: f64,
|
||||
}
|
||||
|
||||
pub struct TimestampedCommand {
|
||||
pub cmd: String,
|
||||
pub time: Option<f64>,
|
||||
}
|
||||
|
||||
pub(crate) struct TickOutput {
|
||||
pub audio_commands: Vec<String>,
|
||||
pub audio_commands: Vec<TimestampedCommand>,
|
||||
pub new_tempo: Option<f64>,
|
||||
pub shared_state: SharedSequencerState,
|
||||
}
|
||||
@@ -422,7 +440,7 @@ pub(crate) struct SequencerState {
|
||||
variables: Variables,
|
||||
speed_overrides: HashMap<(usize, usize), f64>,
|
||||
key_cache: KeyCache,
|
||||
buf_audio_commands: Vec<String>,
|
||||
buf_audio_commands: Vec<TimestampedCommand>,
|
||||
}
|
||||
|
||||
impl SequencerState {
|
||||
@@ -516,7 +534,17 @@ impl SequencerState {
|
||||
let stopped = self.deactivate_pending(beat, prev_beat, input.quantum);
|
||||
self.audio_state.pending_stops.retain(|p| !stopped.contains(&p.id));
|
||||
|
||||
let steps = self.execute_steps(beat, prev_beat, input.tempo, input.quantum, input.fill, input.nudge_secs);
|
||||
let steps = self.execute_steps(
|
||||
beat,
|
||||
prev_beat,
|
||||
input.tempo,
|
||||
input.quantum,
|
||||
input.fill,
|
||||
input.nudge_secs,
|
||||
input.current_time_us,
|
||||
input.engine_time,
|
||||
input.lookahead_secs,
|
||||
);
|
||||
|
||||
let vars = self.read_variables(&steps.completed_iterations, &stopped, steps.any_step_fired);
|
||||
self.apply_chain_transitions(vars.chain_transitions);
|
||||
@@ -591,6 +619,7 @@ impl SequencerState {
|
||||
stopped
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn execute_steps(
|
||||
&mut self,
|
||||
beat: f64,
|
||||
@@ -599,6 +628,9 @@ impl SequencerState {
|
||||
quantum: f64,
|
||||
fill: bool,
|
||||
nudge_secs: f64,
|
||||
_current_time_us: i64,
|
||||
engine_time: f64,
|
||||
lookahead_secs: f64,
|
||||
) -> StepResult {
|
||||
self.buf_audio_commands.clear();
|
||||
let mut result = StepResult {
|
||||
@@ -670,9 +702,19 @@ impl SequencerState {
|
||||
(active.bank, active.pattern, source_idx),
|
||||
std::mem::take(&mut trace),
|
||||
);
|
||||
|
||||
let event_time = if lookahead_secs > 0.0 {
|
||||
Some(engine_time + lookahead_secs)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for cmd in cmds {
|
||||
self.event_count += 1;
|
||||
self.buf_audio_commands.push(cmd);
|
||||
self.buf_audio_commands.push(TimestampedCommand {
|
||||
cmd,
|
||||
time: event_time,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -790,6 +832,9 @@ fn sequencer_loop(
|
||||
shared_state: Arc<ArcSwap<SharedSequencerState>>,
|
||||
live_keys: Arc<LiveKeyState>,
|
||||
nudge_us: Arc<AtomicI64>,
|
||||
audio_sample_pos: Arc<AtomicU64>,
|
||||
sample_rate: Arc<std::sync::atomic::AtomicU32>,
|
||||
lookahead_ms: Arc<std::sync::atomic::AtomicU32>,
|
||||
) {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
@@ -807,10 +852,15 @@ fn sequencer_loop(
|
||||
}
|
||||
|
||||
let state = link.capture_app_state();
|
||||
let time = link.clock_micros();
|
||||
let beat = state.beat_at_time(time, quantum);
|
||||
let current_time_us = link.clock_micros();
|
||||
let beat = state.beat_at_time(current_time_us, quantum);
|
||||
let tempo = state.tempo();
|
||||
|
||||
let sr = sample_rate.load(Ordering::Relaxed) as f64;
|
||||
let audio_samples = audio_sample_pos.load(Ordering::Relaxed);
|
||||
let engine_time = if sr > 0.0 { audio_samples as f64 / sr } else { 0.0 };
|
||||
let lookahead_secs = lookahead_ms.load(Ordering::Relaxed) as f64 / 1000.0;
|
||||
|
||||
let input = TickInput {
|
||||
commands,
|
||||
playing: playing.load(Ordering::Relaxed),
|
||||
@@ -819,15 +869,18 @@ fn sequencer_loop(
|
||||
quantum,
|
||||
fill: live_keys.fill(),
|
||||
nudge_secs: nudge_us.load(Ordering::Relaxed) as f64 / 1_000_000.0,
|
||||
current_time_us,
|
||||
engine_time,
|
||||
lookahead_secs,
|
||||
};
|
||||
|
||||
let output = seq_state.tick(input);
|
||||
|
||||
for cmd in output.audio_commands {
|
||||
match audio_tx.load().try_send(AudioCommand::Evaluate(cmd)) {
|
||||
for tsc in output.audio_commands {
|
||||
let cmd = AudioCommand::Evaluate { cmd: tsc.cmd, time: tsc.time };
|
||||
match audio_tx.load().try_send(cmd) {
|
||||
Ok(()) => {}
|
||||
Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
|
||||
// Lags one tick in shared state: build_shared_state() already ran
|
||||
seq_state.dropped_events += 1;
|
||||
}
|
||||
}
|
||||
@@ -886,6 +939,9 @@ mod tests {
|
||||
quantum: 4.0,
|
||||
fill: false,
|
||||
nudge_secs: 0.0,
|
||||
current_time_us: 0,
|
||||
engine_time: 0.0,
|
||||
lookahead_secs: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -898,6 +954,9 @@ mod tests {
|
||||
quantum: 4.0,
|
||||
fill: false,
|
||||
nudge_secs: 0.0,
|
||||
current_time_us: 0,
|
||||
engine_time: 0.0,
|
||||
lookahead_secs: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
22
src/input.rs
22
src/input.rs
@@ -1,7 +1,7 @@
|
||||
use arc_swap::ArcSwap;
|
||||
use crossbeam_channel::Sender;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -28,6 +28,7 @@ pub struct InputContext<'a> {
|
||||
pub audio_tx: &'a ArcSwap<Sender<AudioCommand>>,
|
||||
pub seq_cmd_tx: &'a Sender<SeqCommand>,
|
||||
pub nudge_us: &'a Arc<AtomicI64>,
|
||||
pub lookahead_ms: &'a Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl<'a> InputContext<'a> {
|
||||
@@ -697,7 +698,7 @@ fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
let folder = &entry.folder;
|
||||
let idx = entry.index;
|
||||
let cmd = format!("/sound/{folder}/n/{idx}/gain/0.5/dur/1");
|
||||
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate(cmd));
|
||||
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate { cmd, time: None });
|
||||
}
|
||||
_ => state.toggle_expand(),
|
||||
}
|
||||
@@ -1082,6 +1083,11 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
ctx.nudge_us
|
||||
.store((prev - 1000).max(-100_000), Ordering::Relaxed);
|
||||
}
|
||||
SettingKind::Lookahead => {
|
||||
ctx.app.audio.adjust_lookahead(-1);
|
||||
ctx.lookahead_ms
|
||||
.store(ctx.app.audio.config.lookahead_ms, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
ctx.app.save_settings(ctx.link);
|
||||
}
|
||||
@@ -1101,6 +1107,11 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
ctx.nudge_us
|
||||
.store((prev + 1000).min(100_000), Ordering::Relaxed);
|
||||
}
|
||||
SettingKind::Lookahead => {
|
||||
ctx.app.audio.adjust_lookahead(1);
|
||||
ctx.lookahead_ms
|
||||
.store(ctx.app.audio.config.lookahead_ms, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
ctx.app.save_settings(ctx.link);
|
||||
}
|
||||
@@ -1134,9 +1145,10 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
KeyCode::Char('r') => ctx.app.metrics.peak_voices = 0,
|
||||
KeyCode::Char('t') => {
|
||||
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate(
|
||||
"/sound/sine/dur/0.5/decay/0.2".into(),
|
||||
));
|
||||
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate {
|
||||
cmd: "/sound/sine/dur/0.5/decay/0.2".into(),
|
||||
time: None,
|
||||
});
|
||||
}
|
||||
KeyCode::Char('?') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
||||
|
||||
24
src/main.rs
24
src/main.rs
@@ -12,7 +12,7 @@ mod widgets;
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -28,7 +28,8 @@ use ratatui::Terminal;
|
||||
|
||||
use app::App;
|
||||
use engine::{
|
||||
build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer, SpectrumBuffer,
|
||||
build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer, SequencerConfig,
|
||||
SpectrumBuffer,
|
||||
};
|
||||
use input::{handle_key, InputContext, InputResult};
|
||||
use settings::Settings;
|
||||
@@ -88,6 +89,7 @@ fn main() -> io::Result<()> {
|
||||
app.audio.config.channels = args.channels.unwrap_or(settings.audio.channels);
|
||||
app.audio.config.buffer_size = args.buffer.unwrap_or(settings.audio.buffer_size);
|
||||
app.audio.config.max_voices = settings.audio.max_voices;
|
||||
app.audio.config.lookahead_ms = settings.audio.lookahead_ms;
|
||||
app.audio.config.sample_paths = args.samples;
|
||||
app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps);
|
||||
app.ui.runtime_highlight = settings.display.runtime_highlight;
|
||||
@@ -100,6 +102,10 @@ fn main() -> io::Result<()> {
|
||||
let scope_buffer = Arc::new(ScopeBuffer::new());
|
||||
let spectrum_buffer = Arc::new(SpectrumBuffer::new());
|
||||
|
||||
let audio_sample_pos = Arc::new(AtomicU64::new(0));
|
||||
let sample_rate_shared = Arc::new(AtomicU32::new(44100));
|
||||
let lookahead_ms = Arc::new(AtomicU32::new(settings.audio.lookahead_ms));
|
||||
|
||||
let mut initial_samples = Vec::new();
|
||||
for path in &app.audio.config.sample_paths {
|
||||
let index = doux::loader::scan_samples_dir(path);
|
||||
@@ -107,6 +113,12 @@ fn main() -> io::Result<()> {
|
||||
initial_samples.extend(index);
|
||||
}
|
||||
|
||||
let seq_config = SequencerConfig {
|
||||
audio_sample_pos: Arc::clone(&audio_sample_pos),
|
||||
sample_rate: Arc::clone(&sample_rate_shared),
|
||||
lookahead_ms: Arc::clone(&lookahead_ms),
|
||||
};
|
||||
|
||||
let (sequencer, initial_audio_rx) = spawn_sequencer(
|
||||
Arc::clone(&link),
|
||||
Arc::clone(&playing),
|
||||
@@ -116,6 +128,7 @@ fn main() -> io::Result<()> {
|
||||
settings.link.quantum,
|
||||
Arc::clone(&app.live_keys),
|
||||
Arc::clone(&nudge_us),
|
||||
seq_config,
|
||||
);
|
||||
|
||||
let stream_config = AudioStreamConfig {
|
||||
@@ -132,9 +145,11 @@ fn main() -> io::Result<()> {
|
||||
Arc::clone(&spectrum_buffer),
|
||||
Arc::clone(&metrics),
|
||||
initial_samples,
|
||||
Arc::clone(&audio_sample_pos),
|
||||
) {
|
||||
Ok((s, sample_rate, analysis)) => {
|
||||
app.audio.config.sample_rate = sample_rate;
|
||||
sample_rate_shared.store(sample_rate as u32, Ordering::Relaxed);
|
||||
(Some(s), Some(analysis))
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -174,6 +189,8 @@ fn main() -> io::Result<()> {
|
||||
}
|
||||
app.audio.config.sample_count = restart_samples.len();
|
||||
|
||||
audio_sample_pos.store(0, Ordering::Relaxed);
|
||||
|
||||
match build_stream(
|
||||
&new_config,
|
||||
new_audio_rx,
|
||||
@@ -181,11 +198,13 @@ fn main() -> io::Result<()> {
|
||||
Arc::clone(&spectrum_buffer),
|
||||
Arc::clone(&metrics),
|
||||
restart_samples,
|
||||
Arc::clone(&audio_sample_pos),
|
||||
) {
|
||||
Ok((new_stream, sr, new_analysis)) => {
|
||||
_stream = Some(new_stream);
|
||||
_analysis_handle = Some(new_analysis);
|
||||
app.audio.config.sample_rate = sr;
|
||||
sample_rate_shared.store(sr as u32, Ordering::Relaxed);
|
||||
app.audio.error = None;
|
||||
app.ui.set_status("Audio restarted".to_string());
|
||||
}
|
||||
@@ -241,6 +260,7 @@ fn main() -> io::Result<()> {
|
||||
audio_tx: &sequencer.audio_tx,
|
||||
seq_cmd_tx: &sequencer.cmd_tx,
|
||||
nudge_us: &nudge_us,
|
||||
lookahead_ms: &lookahead_ms,
|
||||
};
|
||||
|
||||
if let InputResult::Quit = handle_key(&mut ctx, key) {
|
||||
|
||||
@@ -17,9 +17,12 @@ pub struct AudioSettings {
|
||||
pub buffer_size: u32,
|
||||
#[serde(default = "default_max_voices")]
|
||||
pub max_voices: usize,
|
||||
#[serde(default = "default_lookahead_ms")]
|
||||
pub lookahead_ms: u32,
|
||||
}
|
||||
|
||||
fn default_max_voices() -> usize { 32 }
|
||||
fn default_lookahead_ms() -> u32 { 15 }
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DisplaySettings {
|
||||
@@ -50,6 +53,7 @@ impl Default for AudioSettings {
|
||||
channels: 2,
|
||||
buffer_size: 512,
|
||||
max_voices: 32,
|
||||
lookahead_ms: 15,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ pub struct AudioConfig {
|
||||
pub refresh_rate: RefreshRate,
|
||||
pub show_scope: bool,
|
||||
pub show_spectrum: bool,
|
||||
pub lookahead_ms: u32,
|
||||
}
|
||||
|
||||
impl Default for AudioConfig {
|
||||
@@ -75,6 +76,7 @@ impl Default for AudioConfig {
|
||||
refresh_rate: RefreshRate::default(),
|
||||
show_scope: true,
|
||||
show_spectrum: true,
|
||||
lookahead_ms: 15,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,6 +142,7 @@ pub enum SettingKind {
|
||||
BufferSize,
|
||||
Polyphony,
|
||||
Nudge,
|
||||
Lookahead,
|
||||
}
|
||||
|
||||
impl SettingKind {
|
||||
@@ -148,16 +151,18 @@ impl SettingKind {
|
||||
Self::Channels => Self::BufferSize,
|
||||
Self::BufferSize => Self::Polyphony,
|
||||
Self::Polyphony => Self::Nudge,
|
||||
Self::Nudge => Self::Channels,
|
||||
Self::Nudge => Self::Lookahead,
|
||||
Self::Lookahead => Self::Channels,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev(self) -> Self {
|
||||
match self {
|
||||
Self::Channels => Self::Nudge,
|
||||
Self::Channels => Self::Lookahead,
|
||||
Self::BufferSize => Self::Channels,
|
||||
Self::Polyphony => Self::BufferSize,
|
||||
Self::Nudge => Self::Polyphony,
|
||||
Self::Lookahead => Self::Nudge,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,6 +302,11 @@ impl AudioSettings {
|
||||
self.config.max_voices = new_val;
|
||||
}
|
||||
|
||||
pub fn adjust_lookahead(&mut self, delta: i32) {
|
||||
let new_val = (self.config.lookahead_ms as i32 + delta).clamp(0, 50) as u32;
|
||||
self.config.lookahead_ms = new_val;
|
||||
}
|
||||
|
||||
pub fn toggle_refresh_rate(&mut self) {
|
||||
self.config.refresh_rate = self.config.refresh_rate.toggle();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::widgets::{Orientation, Scope, Spectrum};
|
||||
|
||||
const HEADER_COLOR: Color = Color::Rgb(100, 160, 180);
|
||||
const DIVIDER_COLOR: Color = Color::Rgb(60, 65, 70);
|
||||
const SCROLL_INDICATOR_COLOR: Color = Color::Rgb(80, 85, 95);
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let [left_col, _, right_col] = Layout::horizontal([
|
||||
@@ -40,20 +41,105 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
|
||||
height: inner.height.saturating_sub(1),
|
||||
};
|
||||
|
||||
let devices_height = devices_section_height(app);
|
||||
// Calculate section heights
|
||||
let devices_lines = devices_section_height(app) as usize;
|
||||
let settings_lines: usize = 8; // header(1) + divider(1) + 6 rows
|
||||
let samples_lines: usize = 6; // header(1) + divider(1) + content(3) + hint(1)
|
||||
let total_lines = devices_lines + 1 + settings_lines + 1 + samples_lines;
|
||||
|
||||
let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([
|
||||
Constraint::Length(devices_height),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(8),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(6),
|
||||
])
|
||||
.areas(padded);
|
||||
let max_visible = padded.height as usize;
|
||||
|
||||
render_devices(frame, app, devices_area);
|
||||
render_settings(frame, app, settings_area);
|
||||
render_samples(frame, app, samples_area);
|
||||
// Calculate scroll offset based on focused section
|
||||
let (focus_start, focus_height) = match app.audio.section {
|
||||
EngineSection::Devices => (0, devices_lines),
|
||||
EngineSection::Settings => (devices_lines + 1, settings_lines),
|
||||
EngineSection::Samples => (devices_lines + 1 + settings_lines + 1, samples_lines),
|
||||
};
|
||||
|
||||
let scroll_offset = if total_lines <= max_visible {
|
||||
0
|
||||
} else {
|
||||
// Keep focused section in view (top-aligned when possible)
|
||||
let focus_end = focus_start + focus_height;
|
||||
if focus_end <= max_visible {
|
||||
0
|
||||
} else {
|
||||
focus_start.min(total_lines.saturating_sub(max_visible))
|
||||
}
|
||||
};
|
||||
|
||||
let viewport_top = padded.y as i32;
|
||||
let viewport_bottom = (padded.y + padded.height) as i32;
|
||||
|
||||
// Render each section at adjusted position
|
||||
let mut y = viewport_top - scroll_offset as i32;
|
||||
|
||||
// Devices section
|
||||
let devices_top = y;
|
||||
let devices_bottom = y + devices_lines as i32;
|
||||
if devices_bottom > viewport_top && devices_top < viewport_bottom {
|
||||
let clipped_y = devices_top.max(viewport_top) as u16;
|
||||
let clipped_height =
|
||||
(devices_bottom.min(viewport_bottom) - devices_top.max(viewport_top)) as u16;
|
||||
let devices_area = Rect {
|
||||
x: padded.x,
|
||||
y: clipped_y,
|
||||
width: padded.width,
|
||||
height: clipped_height,
|
||||
};
|
||||
render_devices(frame, app, devices_area);
|
||||
}
|
||||
y += devices_lines as i32 + 1; // +1 for blank line
|
||||
|
||||
// Settings section
|
||||
let settings_top = y;
|
||||
let settings_bottom = y + settings_lines as i32;
|
||||
if settings_bottom > viewport_top && settings_top < viewport_bottom {
|
||||
let clipped_y = settings_top.max(viewport_top) as u16;
|
||||
let clipped_height =
|
||||
(settings_bottom.min(viewport_bottom) - settings_top.max(viewport_top)) as u16;
|
||||
let settings_area = Rect {
|
||||
x: padded.x,
|
||||
y: clipped_y,
|
||||
width: padded.width,
|
||||
height: clipped_height,
|
||||
};
|
||||
render_settings(frame, app, settings_area);
|
||||
}
|
||||
y += settings_lines as i32 + 1;
|
||||
|
||||
// Samples section
|
||||
let samples_top = y;
|
||||
let samples_bottom = y + samples_lines as i32;
|
||||
if samples_bottom > viewport_top && samples_top < viewport_bottom {
|
||||
let clipped_y = samples_top.max(viewport_top) as u16;
|
||||
let clipped_height =
|
||||
(samples_bottom.min(viewport_bottom) - samples_top.max(viewport_top)) as u16;
|
||||
let samples_area = Rect {
|
||||
x: padded.x,
|
||||
y: clipped_y,
|
||||
width: padded.width,
|
||||
height: clipped_height,
|
||||
};
|
||||
render_samples(frame, app, samples_area);
|
||||
}
|
||||
|
||||
// Scroll indicators
|
||||
let indicator_style = Style::new().fg(SCROLL_INDICATOR_COLOR);
|
||||
let indicator_x = padded.x + padded.width.saturating_sub(1);
|
||||
|
||||
if scroll_offset > 0 {
|
||||
let up_indicator = Paragraph::new("▲").style(indicator_style);
|
||||
frame.render_widget(up_indicator, Rect::new(indicator_x, padded.y, 1, 1));
|
||||
}
|
||||
|
||||
if scroll_offset + max_visible < total_lines {
|
||||
let down_indicator = Paragraph::new("▼").style(indicator_style);
|
||||
frame.render_widget(
|
||||
down_indicator,
|
||||
Rect::new(indicator_x, padded.y + padded.height.saturating_sub(1), 1, 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_visualizers(frame: &mut Frame, app: &App, area: Rect) {
|
||||
@@ -241,6 +327,7 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let buffer_focused = section_focused && app.audio.setting_kind == SettingKind::BufferSize;
|
||||
let polyphony_focused = section_focused && app.audio.setting_kind == SettingKind::Polyphony;
|
||||
let nudge_focused = section_focused && app.audio.setting_kind == SettingKind::Nudge;
|
||||
let lookahead_focused = section_focused && app.audio.setting_kind == SettingKind::Lookahead;
|
||||
|
||||
let nudge_ms = app.metrics.nudge_ms;
|
||||
let nudge_label = if nudge_ms == 0.0 {
|
||||
@@ -249,6 +336,12 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
|
||||
format!("{nudge_ms:+.1} ms")
|
||||
};
|
||||
|
||||
let lookahead_label = if app.audio.config.lookahead_ms == 0 {
|
||||
"off".to_string()
|
||||
} else {
|
||||
format!("{} ms", app.audio.config.lookahead_ms)
|
||||
};
|
||||
|
||||
let rows = vec![
|
||||
Row::new(vec![
|
||||
Span::styled(
|
||||
@@ -305,6 +398,17 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
|
||||
),
|
||||
render_selector(&nudge_label, nudge_focused, highlight, normal),
|
||||
]),
|
||||
Row::new(vec![
|
||||
Span::styled(
|
||||
if lookahead_focused {
|
||||
"> Lookahead"
|
||||
} else {
|
||||
" Lookahead"
|
||||
},
|
||||
label_style,
|
||||
),
|
||||
render_selector(&lookahead_label, lookahead_focused, highlight, normal),
|
||||
]),
|
||||
Row::new(vec![
|
||||
Span::styled(" Sample rate", label_style),
|
||||
Span::styled(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
@@ -11,6 +11,7 @@ use crate::state::OptionsFocus;
|
||||
const LABEL_COLOR: Color = Color::Rgb(120, 125, 135);
|
||||
const HEADER_COLOR: Color = Color::Rgb(100, 160, 180);
|
||||
const DIVIDER_COLOR: Color = Color::Rgb(60, 65, 70);
|
||||
const SCROLL_INDICATOR_COLOR: Color = Color::Rgb(80, 85, 95);
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
let block = Block::default()
|
||||
@@ -28,43 +29,59 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
height: inner.height.saturating_sub(2),
|
||||
};
|
||||
|
||||
let [display_area, _, link_area, _, session_area] = Layout::vertical([
|
||||
Constraint::Length(8),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(5),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(5),
|
||||
])
|
||||
.areas(padded);
|
||||
|
||||
render_display_section(frame, app, display_area);
|
||||
render_link_section(frame, app, link, link_area);
|
||||
render_session_section(frame, link, session_area);
|
||||
}
|
||||
|
||||
fn render_display_section(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let [header_area, divider_area, content_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
let header = Line::from(Span::styled(
|
||||
"DISPLAY",
|
||||
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
frame.render_widget(Paragraph::new(header), header_area);
|
||||
|
||||
let divider = "─".repeat(area.width as usize);
|
||||
frame.render_widget(
|
||||
Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)),
|
||||
divider_area,
|
||||
);
|
||||
|
||||
let focus = app.options.focus;
|
||||
let content_width = padded.width as usize;
|
||||
|
||||
// Build link header with status
|
||||
let enabled = link.is_enabled();
|
||||
let peers = link.peers();
|
||||
let (status_text, status_color) = if !enabled {
|
||||
("DISABLED", Color::Rgb(120, 60, 60))
|
||||
} else if peers > 0 {
|
||||
("CONNECTED", Color::Rgb(60, 120, 60))
|
||||
} else {
|
||||
("LISTENING", Color::Rgb(120, 120, 60))
|
||||
};
|
||||
let peer_text = if enabled && peers > 0 {
|
||||
if peers == 1 {
|
||||
" · 1 peer".to_string()
|
||||
} else {
|
||||
format!(" · {peers} peers")
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let link_header = Line::from(vec![
|
||||
Span::styled(
|
||||
"ABLETON LINK",
|
||||
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
status_text,
|
||||
Style::new().fg(status_color).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(peer_text, Style::new().fg(LABEL_COLOR)),
|
||||
]);
|
||||
|
||||
// Prepare values
|
||||
let flash_str = format!("{:.0}%", app.ui.flash_brightness * 100.0);
|
||||
let lines = vec![
|
||||
let quantum_str = format!("{:.0}", link.quantum());
|
||||
let tempo_str = format!("{:.1} BPM", link.tempo());
|
||||
let beat_str = format!("{:.2}", link.beat());
|
||||
let phase_str = format!("{:.2}", link.phase());
|
||||
|
||||
let tempo_style = Style::new()
|
||||
.fg(Color::Rgb(220, 180, 100))
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let value_style = Style::new().fg(Color::Rgb(140, 145, 155));
|
||||
|
||||
// Build flat list of all lines
|
||||
let lines: Vec<Line> = vec![
|
||||
// DISPLAY section (lines 0-7)
|
||||
render_section_header("DISPLAY"),
|
||||
render_divider(content_width),
|
||||
render_option_line(
|
||||
"Refresh rate",
|
||||
app.audio.config.refresh_rate.label(),
|
||||
@@ -94,68 +111,12 @@ fn render_display_section(frame: &mut Frame, app: &App, area: Rect) {
|
||||
if app.ui.show_completion { "On" } else { "Off" },
|
||||
focus == OptionsFocus::ShowCompletion,
|
||||
),
|
||||
render_option_line(
|
||||
"Flash brightness",
|
||||
&flash_str,
|
||||
focus == OptionsFocus::FlashBrightness,
|
||||
),
|
||||
];
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), content_area);
|
||||
}
|
||||
|
||||
fn render_link_section(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
let [header_area, divider_area, content_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
let enabled = link.is_enabled();
|
||||
let peers = link.peers();
|
||||
|
||||
let (status_text, status_color) = if !enabled {
|
||||
("DISABLED", Color::Rgb(120, 60, 60))
|
||||
} else if peers > 0 {
|
||||
("CONNECTED", Color::Rgb(60, 120, 60))
|
||||
} else {
|
||||
("LISTENING", Color::Rgb(120, 120, 60))
|
||||
};
|
||||
|
||||
let peer_text = if enabled && peers > 0 {
|
||||
if peers == 1 {
|
||||
" · 1 peer".to_string()
|
||||
} else {
|
||||
format!(" · {peers} peers")
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let header = Line::from(vec![
|
||||
Span::styled(
|
||||
"ABLETON LINK",
|
||||
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
status_text,
|
||||
Style::new().fg(status_color).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(peer_text, Style::new().fg(LABEL_COLOR)),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(header), header_area);
|
||||
|
||||
let divider = "─".repeat(area.width as usize);
|
||||
frame.render_widget(
|
||||
Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)),
|
||||
divider_area,
|
||||
);
|
||||
|
||||
let focus = app.options.focus;
|
||||
let quantum_str = format!("{:.0}", link.quantum());
|
||||
let lines = vec![
|
||||
render_option_line("Flash brightness", &flash_str, focus == OptionsFocus::FlashBrightness),
|
||||
// Blank line (line 8)
|
||||
Line::from(""),
|
||||
// ABLETON LINK section (lines 9-14)
|
||||
link_header,
|
||||
render_divider(content_width),
|
||||
render_option_line(
|
||||
"Enabled",
|
||||
if link.is_enabled() { "On" } else { "Off" },
|
||||
@@ -171,47 +132,84 @@ fn render_link_section(frame: &mut Frame, app: &App, link: &LinkState, area: Rec
|
||||
focus == OptionsFocus::StartStopSync,
|
||||
),
|
||||
render_option_line("Quantum", &quantum_str, focus == OptionsFocus::Quantum),
|
||||
];
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), content_area);
|
||||
}
|
||||
|
||||
fn render_session_section(frame: &mut Frame, link: &LinkState, area: Rect) {
|
||||
let [header_area, divider_area, content_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
let header = Line::from(Span::styled(
|
||||
"SESSION",
|
||||
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
frame.render_widget(Paragraph::new(header), header_area);
|
||||
|
||||
let divider = "─".repeat(area.width as usize);
|
||||
frame.render_widget(
|
||||
Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)),
|
||||
divider_area,
|
||||
);
|
||||
|
||||
let tempo_style = Style::new()
|
||||
.fg(Color::Rgb(220, 180, 100))
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let value_style = Style::new().fg(Color::Rgb(140, 145, 155));
|
||||
|
||||
let tempo_str = format!("{:.1} BPM", link.tempo());
|
||||
let beat_str = format!("{:.2}", link.beat());
|
||||
let phase_str = format!("{:.2}", link.phase());
|
||||
|
||||
let lines = vec![
|
||||
// Blank line (line 15)
|
||||
Line::from(""),
|
||||
// SESSION section (lines 16-21)
|
||||
render_section_header("SESSION"),
|
||||
render_divider(content_width),
|
||||
render_readonly_line("Tempo", &tempo_str, tempo_style),
|
||||
render_readonly_line("Beat", &beat_str, value_style),
|
||||
render_readonly_line("Phase", &phase_str, value_style),
|
||||
];
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), content_area);
|
||||
let total_lines = lines.len();
|
||||
let max_visible = padded.height as usize;
|
||||
|
||||
// Map focus to line index
|
||||
let focus_line: usize = match focus {
|
||||
OptionsFocus::RefreshRate => 2,
|
||||
OptionsFocus::RuntimeHighlight => 3,
|
||||
OptionsFocus::ShowScope => 4,
|
||||
OptionsFocus::ShowSpectrum => 5,
|
||||
OptionsFocus::ShowCompletion => 6,
|
||||
OptionsFocus::FlashBrightness => 7,
|
||||
OptionsFocus::LinkEnabled => 11,
|
||||
OptionsFocus::StartStopSync => 12,
|
||||
OptionsFocus::Quantum => 13,
|
||||
};
|
||||
|
||||
// Calculate scroll offset to keep focused line visible (centered when possible)
|
||||
let scroll_offset = if total_lines <= max_visible {
|
||||
0
|
||||
} else {
|
||||
focus_line
|
||||
.saturating_sub(max_visible / 2)
|
||||
.min(total_lines.saturating_sub(max_visible))
|
||||
};
|
||||
|
||||
// Render visible portion
|
||||
let visible_end = (scroll_offset + max_visible).min(total_lines);
|
||||
let visible_lines: Vec<Line> = lines
|
||||
.into_iter()
|
||||
.skip(scroll_offset)
|
||||
.take(visible_end - scroll_offset)
|
||||
.collect();
|
||||
|
||||
frame.render_widget(Paragraph::new(visible_lines), padded);
|
||||
|
||||
// Render scroll indicators
|
||||
let indicator_style = Style::new().fg(SCROLL_INDICATOR_COLOR);
|
||||
let indicator_x = padded.x + padded.width.saturating_sub(1);
|
||||
|
||||
if scroll_offset > 0 {
|
||||
let up_indicator = Paragraph::new("▲").style(indicator_style);
|
||||
frame.render_widget(
|
||||
up_indicator,
|
||||
Rect::new(indicator_x, padded.y, 1, 1),
|
||||
);
|
||||
}
|
||||
|
||||
if visible_end < total_lines {
|
||||
let down_indicator = Paragraph::new("▼").style(indicator_style);
|
||||
frame.render_widget(
|
||||
down_indicator,
|
||||
Rect::new(indicator_x, padded.y + padded.height.saturating_sub(1), 1, 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_section_header(title: &str) -> Line<'static> {
|
||||
Line::from(Span::styled(
|
||||
title.to_string(),
|
||||
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
|
||||
))
|
||||
}
|
||||
|
||||
fn render_divider(width: usize) -> Line<'static> {
|
||||
Line::from(Span::styled(
|
||||
"─".repeat(width),
|
||||
Style::new().fg(DIVIDER_COLOR),
|
||||
))
|
||||
}
|
||||
|
||||
fn render_option_line<'a>(label: &'a str, value: &'a str, focused: bool) -> Line<'a> {
|
||||
|
||||
@@ -9,6 +9,8 @@ use crate::engine::SequencerSnapshot;
|
||||
use crate::model::{MAX_BANKS, MAX_PATTERNS};
|
||||
use crate::state::PatternsColumn;
|
||||
|
||||
const MIN_ROW_HEIGHT: u16 = 1;
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||
let [banks_area, gap, patterns_area] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
@@ -55,16 +57,25 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
|
||||
})
|
||||
.collect();
|
||||
|
||||
let row_height = (inner.height / MAX_BANKS as u16).max(1);
|
||||
let total_needed = row_height * MAX_BANKS as u16;
|
||||
let top_padding = if inner.height > total_needed {
|
||||
(inner.height - total_needed) / 2
|
||||
} else {
|
||||
let cursor = app.patterns_nav.bank_cursor;
|
||||
let max_visible = (inner.height / MIN_ROW_HEIGHT) as usize;
|
||||
let max_visible = max_visible.max(1);
|
||||
|
||||
let scroll_offset = if MAX_BANKS <= max_visible {
|
||||
0
|
||||
} else {
|
||||
cursor
|
||||
.saturating_sub(max_visible / 2)
|
||||
.min(MAX_BANKS - max_visible)
|
||||
};
|
||||
|
||||
for idx in 0..MAX_BANKS {
|
||||
let y = inner.y + top_padding + (idx as u16) * row_height;
|
||||
let visible_count = MAX_BANKS.min(max_visible);
|
||||
let row_height = inner.height / visible_count as u16;
|
||||
let row_height = row_height.max(MIN_ROW_HEIGHT);
|
||||
|
||||
for visible_idx in 0..visible_count {
|
||||
let idx = scroll_offset + visible_idx;
|
||||
let y = inner.y + (visible_idx as u16) * row_height;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
@@ -126,6 +137,22 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
|
||||
let para = Paragraph::new(label).style(style);
|
||||
frame.render_widget(para, text_area);
|
||||
}
|
||||
|
||||
// Scroll indicators
|
||||
let indicator_style = Style::new().fg(Color::Rgb(120, 125, 135));
|
||||
if scroll_offset > 0 {
|
||||
let indicator = Paragraph::new("▲")
|
||||
.style(indicator_style)
|
||||
.alignment(ratatui::layout::Alignment::Center);
|
||||
frame.render_widget(indicator, Rect { height: 1, ..inner });
|
||||
}
|
||||
if scroll_offset + visible_count < MAX_BANKS {
|
||||
let y = inner.y + inner.height.saturating_sub(1);
|
||||
let indicator = Paragraph::new("▼")
|
||||
.style(indicator_style)
|
||||
.alignment(ratatui::layout::Alignment::Center);
|
||||
frame.render_widget(indicator, Rect { y, height: 1, ..inner });
|
||||
}
|
||||
}
|
||||
|
||||
fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||
@@ -191,16 +218,25 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
||||
None
|
||||
};
|
||||
|
||||
let row_height = (inner.height / MAX_PATTERNS as u16).max(1);
|
||||
let total_needed = row_height * MAX_PATTERNS as u16;
|
||||
let top_padding = if inner.height > total_needed {
|
||||
(inner.height - total_needed) / 2
|
||||
} else {
|
||||
let cursor = app.patterns_nav.pattern_cursor;
|
||||
let max_visible = (inner.height / MIN_ROW_HEIGHT) as usize;
|
||||
let max_visible = max_visible.max(1);
|
||||
|
||||
let scroll_offset = if MAX_PATTERNS <= max_visible {
|
||||
0
|
||||
} else {
|
||||
cursor
|
||||
.saturating_sub(max_visible / 2)
|
||||
.min(MAX_PATTERNS - max_visible)
|
||||
};
|
||||
|
||||
for idx in 0..MAX_PATTERNS {
|
||||
let y = inner.y + top_padding + (idx as u16) * row_height;
|
||||
let visible_count = MAX_PATTERNS.min(max_visible);
|
||||
let row_height = inner.height / visible_count as u16;
|
||||
let row_height = row_height.max(MIN_ROW_HEIGHT);
|
||||
|
||||
for visible_idx in 0..visible_count {
|
||||
let idx = scroll_offset + visible_idx;
|
||||
let y = inner.y + (visible_idx as u16) * row_height;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
@@ -247,52 +283,56 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
||||
row_area.y
|
||||
};
|
||||
|
||||
// Split row into columns: [index+name] [length] [speed]
|
||||
let speed_width: u16 = 14; // "Speed: 1/4x "
|
||||
let length_width: u16 = 13; // "Length: 16 "
|
||||
let name_width = row_area
|
||||
.width
|
||||
.saturating_sub(speed_width + length_width + 2);
|
||||
|
||||
let [name_area, length_area, speed_area] = Layout::horizontal([
|
||||
Constraint::Length(name_width),
|
||||
Constraint::Length(length_width),
|
||||
Constraint::Length(speed_width),
|
||||
])
|
||||
.areas(Rect {
|
||||
let text_area = Rect {
|
||||
x: row_area.x,
|
||||
y: text_y,
|
||||
width: row_area.width,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
// Column 1: prefix + index + name (left-aligned)
|
||||
let name_text = if name.is_empty() {
|
||||
format!("{}{:02}", prefix, idx + 1)
|
||||
} else {
|
||||
format!("{}{:02} {}", prefix, idx + 1, name)
|
||||
};
|
||||
|
||||
// Build the line: [prefix][idx] [name] ... [length] [speed]
|
||||
let name_style = if is_playing || is_staged_play {
|
||||
bold_style
|
||||
} else {
|
||||
base_style
|
||||
};
|
||||
frame.render_widget(Paragraph::new(name_text).style(name_style), name_area);
|
||||
let dim_style = base_style.remove_modifier(Modifier::BOLD);
|
||||
|
||||
// Column 2: length
|
||||
let length_line = Line::from(vec![
|
||||
Span::styled("Length: ", bold_style),
|
||||
Span::styled(format!("{length}"), base_style),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(length_line), length_area);
|
||||
|
||||
// Column 3: speed (only if non-default)
|
||||
if speed != PatternSpeed::NORMAL {
|
||||
let speed_line = Line::from(vec![
|
||||
Span::styled("Speed: ", bold_style),
|
||||
Span::styled(speed.label(), base_style),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(speed_line), speed_area);
|
||||
let mut spans = vec![Span::styled(format!("{}{:02}", prefix, idx + 1), name_style)];
|
||||
if !name.is_empty() {
|
||||
spans.push(Span::styled(format!(" {name}"), name_style));
|
||||
}
|
||||
|
||||
// Right-aligned info: length and speed
|
||||
let speed_str = if speed != PatternSpeed::NORMAL {
|
||||
format!(" {}", speed.label())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let right_info = format!("{length}{speed_str}");
|
||||
let left_width: usize = spans.iter().map(|s| s.content.chars().count()).sum();
|
||||
let right_width = right_info.chars().count();
|
||||
let padding = (text_area.width as usize).saturating_sub(left_width + right_width + 1);
|
||||
|
||||
spans.push(Span::raw(" ".repeat(padding)));
|
||||
spans.push(Span::styled(right_info, dim_style));
|
||||
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)), text_area);
|
||||
}
|
||||
|
||||
// Scroll indicators
|
||||
let indicator_style = Style::new().fg(Color::Rgb(120, 125, 135));
|
||||
if scroll_offset > 0 {
|
||||
let indicator = Paragraph::new("▲")
|
||||
.style(indicator_style)
|
||||
.alignment(ratatui::layout::Alignment::Center);
|
||||
frame.render_widget(indicator, Rect { height: 1, ..inner });
|
||||
}
|
||||
if scroll_offset + visible_count < MAX_PATTERNS {
|
||||
let y = inner.y + inner.height.saturating_sub(1);
|
||||
let indicator = Paragraph::new("▼")
|
||||
.style(indicator_style)
|
||||
.alignment(ratatui::layout::Alignment::Center);
|
||||
frame.render_widget(indicator, Rect { y, height: 1, ..inner });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,12 +430,9 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
("?", "Keys"),
|
||||
],
|
||||
Page::Patterns => vec![
|
||||
("←→↑↓", "Navigate"),
|
||||
("Enter", "Select"),
|
||||
("Space", "Play"),
|
||||
("Esc", "Back"),
|
||||
("r", "Rename"),
|
||||
("Del", "Reset"),
|
||||
("?", "Keys"),
|
||||
],
|
||||
Page::Engine => vec![
|
||||
|
||||
Reference in New Issue
Block a user