diff --git a/Cargo.toml b/Cargo.toml index 3f50863..df220f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/app.rs b/src/app.rs index d8d182b..c468c73 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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(), diff --git a/src/engine/audio.rs b/src/engine/audio.rs index 0e921ee..7e4b349 100644 --- a/src/engine/audio.rs +++ b/src/engine/audio.rs @@ -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, metrics: Arc, initial_samples: Vec, + audio_sample_pos: Arc, ) -> 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::() / channels as f32; diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 33ba94e..9851a3f 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -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, }; diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 80029ad..badbf88 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -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 }, Hush, Panic, LoadSamples(Vec), @@ -199,6 +199,12 @@ impl AudioState { } } +pub struct SequencerConfig { + pub audio_sample_pos: Arc, + pub sample_rate: Arc, + pub lookahead_ms: Arc, +} + #[allow(clippy::too_many_arguments)] pub fn spawn_sequencer( link: Arc, @@ -209,6 +215,7 @@ pub fn spawn_sequencer( quantum: f64, live_keys: Arc, nudge_us: Arc, + config: SequencerConfig, ) -> (SequencerHandle, Receiver) { let (cmd_tx, cmd_rx) = bounded::(64); let (audio_tx, audio_rx) = bounded::(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, } pub(crate) struct TickOutput { - pub audio_commands: Vec, + pub audio_commands: Vec, pub new_tempo: Option, 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, + buf_audio_commands: Vec, } 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>, live_keys: Arc, nudge_us: Arc, + audio_sample_pos: Arc, + sample_rate: Arc, + lookahead_ms: Arc, ) { 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, } } diff --git a/src/input.rs b/src/input.rs index d94e45e..716c053 100644 --- a/src/input.rs +++ b/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>, pub seq_cmd_tx: &'a Sender, pub nudge_us: &'a Arc, + pub lookahead_ms: &'a Arc, } 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 })); diff --git a/src/main.rs b/src/main.rs index 2f02f53..c154e78 100644 --- a/src/main.rs +++ b/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) { diff --git a/src/settings.rs b/src/settings.rs index f5b987d..73f26a9 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -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, } } } diff --git a/src/state/audio.rs b/src/state/audio.rs index ba9c956..af16112 100644 --- a/src/state/audio.rs +++ b/src/state/audio.rs @@ -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(); } diff --git a/src/views/engine_view.rs b/src/views/engine_view.rs index b78646a..bee85d2 100644 --- a/src/views/engine_view.rs +++ b/src/views/engine_view.rs @@ -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( diff --git a/src/views/options_view.rs b/src/views/options_view.rs index 73ef57b..a4c2c18 100644 --- a/src/views/options_view.rs +++ b/src/views/options_view.rs @@ -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 = 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 = 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> { diff --git a/src/views/patterns_view.rs b/src/views/patterns_view.rs index 0243853..934c935 100644 --- a/src/views/patterns_view.rs +++ b/src/views/patterns_view.rs @@ -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 }); } } diff --git a/src/views/render.rs b/src/views/render.rs index 25b4d61..f64bc37 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -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![