diff --git a/crates/ratatui/src/lib.rs b/crates/ratatui/src/lib.rs index 487bfc6..5034f45 100644 --- a/crates/ratatui/src/lib.rs +++ b/crates/ratatui/src/lib.rs @@ -5,6 +5,7 @@ mod confirm; mod editor; mod file_browser; mod hint_bar; +mod lissajous; mod list_select; mod modal; mod nav_minimap; @@ -26,6 +27,7 @@ pub use confirm::ConfirmModal; pub use editor::{fuzzy_match, CompletionCandidate, Editor}; pub use file_browser::FileBrowserModal; pub use hint_bar::hint_line; +pub use lissajous::Lissajous; pub use list_select::ListSelect; pub use modal::ModalFrame; pub use nav_minimap::{hit_test_tile, minimap_area, NavMinimap, NavTile}; diff --git a/crates/ratatui/src/lissajous.rs b/crates/ratatui/src/lissajous.rs new file mode 100644 index 0000000..dd1b906 --- /dev/null +++ b/crates/ratatui/src/lissajous.rs @@ -0,0 +1,103 @@ +use crate::theme; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::widgets::Widget; +use std::cell::RefCell; + +thread_local! { + static PATTERNS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +pub struct Lissajous<'a> { + left: &'a [f32], + right: &'a [f32], + color: Option, +} + +impl<'a> Lissajous<'a> { + pub fn new(left: &'a [f32], right: &'a [f32]) -> Self { + Self { + left, + right, + color: None, + } + } + + pub fn color(mut self, c: Color) -> Self { + self.color = Some(c); + self + } +} + +impl Widget for Lissajous<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 || self.left.is_empty() || self.right.is_empty() { + return; + } + + let color = self.color.unwrap_or_else(|| theme::get().meter.low); + let width = area.width as usize; + let height = area.height as usize; + let fine_width = width * 2; + let fine_height = height * 4; + let len = self.left.len().min(self.right.len()); + + let peak = self + .left + .iter() + .chain(self.right.iter()) + .map(|s| s.abs()) + .fold(0.0f32, f32::max); + let gain = if peak > 0.001 { 1.0 / peak } else { 1.0 }; + + PATTERNS.with(|p| { + let mut patterns = p.borrow_mut(); + let size = width * height; + patterns.clear(); + patterns.resize(size, 0); + + for i in 0..len { + let l = (self.left[i] * gain).clamp(-1.0, 1.0); + let r = (self.right[i] * gain).clamp(-1.0, 1.0); + + // X = right channel, Y = left channel (inverted so up = positive) + let fine_x = ((r + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize; + let fine_y = ((1.0 - l) * 0.5 * (fine_height - 1) as f32).round() as usize; + let fine_x = fine_x.min(fine_width - 1); + let fine_y = fine_y.min(fine_height - 1); + + let char_x = fine_x / 2; + let char_y = fine_y / 4; + let dot_x = fine_x % 2; + let dot_y = fine_y % 4; + + let bit = match (dot_x, dot_y) { + (0, 0) => 0x01, + (0, 1) => 0x02, + (0, 2) => 0x04, + (0, 3) => 0x40, + (1, 0) => 0x08, + (1, 1) => 0x10, + (1, 2) => 0x20, + (1, 3) => 0x80, + _ => unreachable!(), + }; + + patterns[char_y * width + char_x] |= bit; + } + + for cy in 0..height { + for cx in 0..width { + let pattern = patterns[cy * width + cx]; + if pattern != 0 { + let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' '); + buf[(area.x + cx as u16, area.y + cy as u16)] + .set_char(ch) + .set_fg(color); + } + } + } + }); + } +} diff --git a/src/app/dispatch.rs b/src/app/dispatch.rs index 87a3a6e..04329a9 100644 --- a/src/app/dispatch.rs +++ b/src/app/dispatch.rs @@ -389,6 +389,7 @@ impl App { AppCommand::ToggleRefreshRate => self.audio.toggle_refresh_rate(), AppCommand::ToggleScope => self.audio.config.show_scope = !self.audio.config.show_scope, AppCommand::ToggleSpectrum => self.audio.config.show_spectrum = !self.audio.config.show_spectrum, + AppCommand::ToggleLissajous => self.audio.config.show_lissajous = !self.audio.config.show_lissajous, AppCommand::TogglePreview => self.audio.config.show_preview = !self.audio.config.show_preview, AppCommand::TogglePerformanceMode => self.ui.performance_mode = !self.ui.performance_mode, diff --git a/src/app/persistence.rs b/src/app/persistence.rs index da8c67e..a74540a 100644 --- a/src/app/persistence.rs +++ b/src/app/persistence.rs @@ -24,6 +24,7 @@ impl App { runtime_highlight: self.ui.runtime_highlight, show_scope: self.audio.config.show_scope, show_spectrum: self.audio.config.show_spectrum, + show_lissajous: self.audio.config.show_lissajous, show_preview: self.audio.config.show_preview, show_completion: self.ui.show_completion, performance_mode: self.ui.performance_mode, diff --git a/src/bin/desktop/main.rs b/src/bin/desktop/main.rs index 4e16f59..a40cd78 100644 --- a/src/bin/desktop/main.rs +++ b/src/bin/desktop/main.rs @@ -307,6 +307,7 @@ impl CagireDesktop { self.app.metrics.schedule_depth = self.metrics.schedule_depth.load(Ordering::Relaxed) as usize; self.app.metrics.scope = self.scope_buffer.read(); + self.app.metrics.scope_right = self.scope_buffer.read_right(); (self.app.metrics.peak_left, self.app.metrics.peak_right) = self.scope_buffer.peaks(); self.app.metrics.spectrum = self.spectrum_buffer.read(); self.app.metrics.nudge_ms = self.nudge_us.load(Ordering::Relaxed) as f64 / 1000.0; diff --git a/src/commands.rs b/src/commands.rs index a70a140..0493508 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -253,6 +253,7 @@ pub enum AppCommand { ToggleRefreshRate, ToggleScope, ToggleSpectrum, + ToggleLissajous, TogglePreview, TogglePerformanceMode, diff --git a/src/engine/audio.rs b/src/engine/audio.rs index 41dbb70..1963893 100644 --- a/src/engine/audio.rs +++ b/src/engine/audio.rs @@ -11,6 +11,7 @@ use std::sync::atomic::AtomicU64; pub struct ScopeBuffer { pub samples: [AtomicU32; 256], + pub samples_right: [AtomicU32; 256], peak_left: AtomicU32, peak_right: AtomicU32, } @@ -25,6 +26,7 @@ impl ScopeBuffer { pub fn new() -> Self { Self { samples: std::array::from_fn(|_| AtomicU32::new(0)), + samples_right: std::array::from_fn(|_| AtomicU32::new(0)), peak_left: AtomicU32::new(0), peak_right: AtomicU32::new(0), } @@ -44,10 +46,14 @@ impl ScopeBuffer { // Downsample for scope display let frames = data.len() / 2; - for (i, atom) in self.samples.iter().enumerate() { + for (i, (left_atom, right_atom)) in + self.samples.iter().zip(self.samples_right.iter()).enumerate() + { let frame_idx = (i * frames) / self.samples.len(); let left = data.get(frame_idx * 2).copied().unwrap_or(0.0); - atom.store(left.to_bits(), Ordering::Relaxed); + let right = data.get(frame_idx * 2 + 1).copied().unwrap_or(0.0); + left_atom.store(left.to_bits(), Ordering::Relaxed); + right_atom.store(right.to_bits(), Ordering::Relaxed); } self.peak_left.store(peak_l.to_bits(), Ordering::Relaxed); @@ -58,6 +64,10 @@ impl ScopeBuffer { std::array::from_fn(|i| f32::from_bits(self.samples[i].load(Ordering::Relaxed))) } + pub fn read_right(&self) -> [f32; 256] { + std::array::from_fn(|i| f32::from_bits(self.samples_right[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)); diff --git a/src/init.rs b/src/init.rs index 9e119cc..f62c36e 100644 --- a/src/init.rs +++ b/src/init.rs @@ -109,6 +109,7 @@ pub fn init(args: InitArgs) -> Init { app.ui.runtime_highlight = settings.display.runtime_highlight; app.audio.config.show_scope = settings.display.show_scope; app.audio.config.show_spectrum = settings.display.show_spectrum; + app.audio.config.show_lissajous = settings.display.show_lissajous; app.audio.config.show_preview = settings.display.show_preview; app.ui.show_completion = settings.display.show_completion; app.ui.performance_mode = settings.display.performance_mode; diff --git a/src/input/options_page.rs b/src/input/options_page.rs index 16f02bc..50f0e2b 100644 --- a/src/input/options_page.rs +++ b/src/input/options_page.rs @@ -24,6 +24,7 @@ pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) { OptionsFocus::RuntimeHighlight => ctx.dispatch(AppCommand::ToggleRuntimeHighlight), OptionsFocus::ShowScope => ctx.dispatch(AppCommand::ToggleScope), OptionsFocus::ShowSpectrum => ctx.dispatch(AppCommand::ToggleSpectrum), + OptionsFocus::ShowLissajous => ctx.dispatch(AppCommand::ToggleLissajous), OptionsFocus::ShowCompletion => ctx.dispatch(AppCommand::ToggleCompletion), OptionsFocus::ShowPreview => ctx.dispatch(AppCommand::TogglePreview), OptionsFocus::PerformanceMode => ctx.dispatch(AppCommand::TogglePerformanceMode), diff --git a/src/main.rs b/src/main.rs index 0b2cf7c..99ce51d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -231,6 +231,7 @@ 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.scope_right = scope_buffer.read_right(); (app.metrics.peak_left, app.metrics.peak_right) = scope_buffer.peaks(); app.metrics.spectrum = spectrum_buffer.read(); app.metrics.nudge_ms = nudge_us.load(Ordering::Relaxed) as f64 / 1000.0; diff --git a/src/settings.rs b/src/settings.rs index 3751b52..e5c9432 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -41,6 +41,8 @@ pub struct DisplaySettings { pub show_scope: bool, pub show_spectrum: bool, #[serde(default = "default_true")] + pub show_lissajous: bool, + #[serde(default = "default_true")] pub show_preview: bool, #[serde(default = "default_true")] pub show_completion: bool, @@ -100,6 +102,7 @@ impl Default for DisplaySettings { runtime_highlight: false, show_scope: true, show_spectrum: true, + show_lissajous: true, show_preview: true, show_completion: true, font: default_font(), diff --git a/src/state/audio.rs b/src/state/audio.rs index 78cfa08..442b254 100644 --- a/src/state/audio.rs +++ b/src/state/audio.rs @@ -83,6 +83,7 @@ pub struct AudioConfig { pub refresh_rate: RefreshRate, pub show_scope: bool, pub show_spectrum: bool, + pub show_lissajous: bool, pub show_preview: bool, pub layout: MainLayout, } @@ -102,6 +103,7 @@ impl Default for AudioConfig { refresh_rate: RefreshRate::default(), show_scope: true, show_spectrum: true, + show_lissajous: true, show_preview: true, layout: MainLayout::default(), } @@ -191,6 +193,7 @@ pub struct Metrics { pub cpu_load: f32, pub schedule_depth: usize, pub scope: [f32; 256], + pub scope_right: [f32; 256], pub peak_left: f32, pub peak_right: f32, pub spectrum: [f32; 32], @@ -206,6 +209,7 @@ impl Default for Metrics { cpu_load: 0.0, schedule_depth: 0, scope: [0.0; 256], + scope_right: [0.0; 256], peak_left: 0.0, peak_right: 0.0, spectrum: [0.0; 32], diff --git a/src/state/options.rs b/src/state/options.rs index b66438c..96c18ba 100644 --- a/src/state/options.rs +++ b/src/state/options.rs @@ -9,6 +9,7 @@ pub enum OptionsFocus { RuntimeHighlight, ShowScope, ShowSpectrum, + ShowLissajous, ShowCompletion, ShowPreview, PerformanceMode, @@ -38,6 +39,7 @@ impl CyclicEnum for OptionsFocus { Self::RuntimeHighlight, Self::ShowScope, Self::ShowSpectrum, + Self::ShowLissajous, Self::ShowCompletion, Self::ShowPreview, Self::PerformanceMode, @@ -93,30 +95,31 @@ const FULL_LAYOUT: &[(OptionsFocus, usize)] = &[ (OptionsFocus::RuntimeHighlight, 5), (OptionsFocus::ShowScope, 6), (OptionsFocus::ShowSpectrum, 7), - (OptionsFocus::ShowCompletion, 8), - (OptionsFocus::ShowPreview, 9), - (OptionsFocus::PerformanceMode, 10), - (OptionsFocus::Font, 11), - (OptionsFocus::ZoomFactor, 12), - (OptionsFocus::WindowSize, 13), - // blank=14, ABLETON LINK header=15, divider=16 - (OptionsFocus::LinkEnabled, 17), - (OptionsFocus::StartStopSync, 18), - (OptionsFocus::Quantum, 19), - // blank=20, SESSION header=21, divider=22, Tempo=23, Beat=24, Phase=25 - // blank=26, MIDI OUTPUTS header=27, divider=28 - (OptionsFocus::MidiOutput0, 29), - (OptionsFocus::MidiOutput1, 30), - (OptionsFocus::MidiOutput2, 31), - (OptionsFocus::MidiOutput3, 32), - // blank=33, MIDI INPUTS header=34, divider=35 - (OptionsFocus::MidiInput0, 36), - (OptionsFocus::MidiInput1, 37), - (OptionsFocus::MidiInput2, 38), - (OptionsFocus::MidiInput3, 39), - // blank=40, ONBOARDING header=41, divider=42 - (OptionsFocus::ResetOnboarding, 43), - (OptionsFocus::LoadDemoOnStartup, 44), + (OptionsFocus::ShowLissajous, 8), + (OptionsFocus::ShowCompletion, 9), + (OptionsFocus::ShowPreview, 10), + (OptionsFocus::PerformanceMode, 11), + (OptionsFocus::Font, 12), + (OptionsFocus::ZoomFactor, 13), + (OptionsFocus::WindowSize, 14), + // blank=15, ABLETON LINK header=16, divider=17 + (OptionsFocus::LinkEnabled, 18), + (OptionsFocus::StartStopSync, 19), + (OptionsFocus::Quantum, 20), + // blank=21, SESSION header=22, divider=23, Tempo=24, Beat=25, Phase=26 + // blank=27, MIDI OUTPUTS header=28, divider=29 + (OptionsFocus::MidiOutput0, 30), + (OptionsFocus::MidiOutput1, 31), + (OptionsFocus::MidiOutput2, 32), + (OptionsFocus::MidiOutput3, 33), + // blank=34, MIDI INPUTS header=35, divider=36 + (OptionsFocus::MidiInput0, 37), + (OptionsFocus::MidiInput1, 38), + (OptionsFocus::MidiInput2, 39), + (OptionsFocus::MidiInput3, 40), + // blank=41, ONBOARDING header=42, divider=43 + (OptionsFocus::ResetOnboarding, 44), + (OptionsFocus::LoadDemoOnStartup, 45), ]; impl OptionsFocus { @@ -172,13 +175,13 @@ fn visible_layout(plugin_mode: bool) -> Vec<(OptionsFocus, usize)> { // based on which sections are hidden. let mut offset: usize = 0; - // Font/Zoom/Window lines (11,12,13) hidden when !plugin_mode + // Font/Zoom/Window lines (12,13,14) hidden when !plugin_mode if !plugin_mode { offset += 3; // 3 lines for Font, ZoomFactor, WindowSize } // Link + Session + MIDI sections hidden when plugin_mode - // These span from blank(14) through MidiInput3(39) = 26 lines + // These span from blank(15) through MidiInput3(40) = 26 lines if plugin_mode { let link_section_lines = 26; offset += link_section_lines; @@ -189,10 +192,10 @@ fn visible_layout(plugin_mode: bool) -> Vec<(OptionsFocus, usize)> { if !focus.is_visible(plugin_mode) { continue; } - // Lines at or below index 10 (PerformanceMode) are never shifted - let adjusted = if raw_line <= 10 { + // Lines at or below index 11 (PerformanceMode) are never shifted + let adjusted = if raw_line <= 11 { raw_line - } else if !plugin_mode && raw_line <= 13 { + } else if !plugin_mode && raw_line <= 14 { // Font/Zoom/Window — these are hidden, skip continue; } else { diff --git a/src/views/engine_view.rs b/src/views/engine_view.rs index 3749dcb..77a387a 100644 --- a/src/views/engine_view.rs +++ b/src/views/engine_view.rs @@ -9,7 +9,8 @@ use crate::app::App; use crate::state::{DeviceKind, EngineSection, SettingKind}; use crate::theme; use crate::widgets::{ - render_scroll_indicators, render_section_header, IndicatorAlign, Orientation, Scope, Spectrum, + render_scroll_indicators, render_section_header, IndicatorAlign, Lissajous, Orientation, Scope, + Spectrum, }; pub fn layout(area: Rect) -> [Rect; 3] { @@ -148,14 +149,17 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) { } fn render_visualizers(frame: &mut Frame, app: &App, area: Rect) { - let [scope_area, _, spectrum_area] = Layout::vertical([ - Constraint::Percentage(50), + let [scope_area, _, lissajous_area, _gap, spectrum_area] = Layout::vertical([ + Constraint::Fill(1), Constraint::Length(1), - Constraint::Percentage(50), + Constraint::Fill(1), + Constraint::Length(1), + Constraint::Fill(1), ]) .areas(area); render_scope(frame, app, scope_area); + render_lissajous(frame, app, lissajous_area); render_spectrum(frame, app, spectrum_area); } @@ -175,6 +179,21 @@ fn render_scope(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(scope, inner); } +fn render_lissajous(frame: &mut Frame, app: &App, area: Rect) { + let theme = theme::get(); + let block = Block::default() + .borders(Borders::ALL) + .title(" Lissajous ") + .border_style(Style::new().fg(theme.engine.border_green)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let lissajous = Lissajous::new(&app.metrics.scope, &app.metrics.scope_right) + .color(theme.meter.low); + frame.render_widget(lissajous, inner); +} + fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let block = Block::default() diff --git a/src/views/main_view.rs b/src/views/main_view.rs index 3a86248..0e4ae67 100644 --- a/src/views/main_view.rs +++ b/src/views/main_view.rs @@ -12,7 +12,7 @@ use crate::engine::SequencerSnapshot; use crate::state::MainLayout; use crate::theme; use crate::views::render::highlight_script_lines; -use crate::widgets::{Orientation, Scope, Spectrum, VuMeter}; +use crate::widgets::{Lissajous, Orientation, Scope, Spectrum, VuMeter}; pub fn layout(area: Rect) -> [Rect; 3] { Layout::horizontal([ @@ -31,6 +31,7 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: } else { let has_viz = app.audio.config.show_scope || app.audio.config.show_spectrum + || app.audio.config.show_lissajous || app.audio.config.show_preview; let (viz_area, sequencer_area) = viz_seq_split(main_area, app.audio.config.layout, has_viz); @@ -49,7 +50,9 @@ fn render_top_layout( snapshot: &SequencerSnapshot, main_area: Rect, ) { - let has_audio_viz = app.audio.config.show_scope || app.audio.config.show_spectrum; + let has_audio_viz = app.audio.config.show_scope + || app.audio.config.show_spectrum + || app.audio.config.show_lissajous; let has_preview = app.audio.config.show_preview; let mut constraints = Vec::new(); @@ -85,16 +88,23 @@ fn render_top_layout( } fn render_audio_viz(frame: &mut Frame, app: &App, area: Rect) { - match (app.audio.config.show_scope, app.audio.config.show_spectrum) { - (true, true) => { - let [scope_area, spectrum_area] = - Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area); - render_scope(frame, app, scope_area, Orientation::Horizontal); - render_spectrum(frame, app, spectrum_area); + let mut panels: Vec = Vec::new(); + if app.audio.config.show_scope { panels.push(VizPanel::Scope); } + if app.audio.config.show_spectrum { panels.push(VizPanel::Spectrum); } + if app.audio.config.show_lissajous { panels.push(VizPanel::Lissajous); } + + if panels.is_empty() { return; } + + let constraints: Vec = panels.iter().map(|_| Constraint::Fill(1)).collect(); + let areas: Vec = Layout::horizontal(&constraints).split(area).to_vec(); + + for (panel, panel_area) in panels.iter().zip(areas.iter()) { + match panel { + VizPanel::Scope => render_scope(frame, app, *panel_area, Orientation::Horizontal), + VizPanel::Spectrum => render_spectrum(frame, app, *panel_area), + VizPanel::Lissajous => render_lissajous(frame, app, *panel_area), + VizPanel::Preview => {} } - (true, false) => render_scope(frame, app, area, Orientation::Horizontal), - (false, true) => render_spectrum(frame, app, area), - (false, false) => {} } } @@ -104,7 +114,9 @@ fn preview_height(has_audio_viz: bool) -> u16 { pub fn sequencer_rect(app: &App, main_area: Rect) -> Rect { if matches!(app.audio.config.layout, MainLayout::Top) { - let has_audio_viz = app.audio.config.show_scope || app.audio.config.show_spectrum; + let has_audio_viz = app.audio.config.show_scope + || app.audio.config.show_spectrum + || app.audio.config.show_lissajous; let has_preview = app.audio.config.show_preview; let mut constraints = Vec::new(); @@ -121,6 +133,7 @@ pub fn sequencer_rect(app: &App, main_area: Rect) -> Rect { } else { let has_viz = app.audio.config.show_scope || app.audio.config.show_spectrum + || app.audio.config.show_lissajous || app.audio.config.show_preview; let (_, seq_area) = viz_seq_split(main_area, app.audio.config.layout, has_viz); seq_area @@ -130,6 +143,7 @@ pub fn sequencer_rect(app: &App, main_area: Rect) -> Rect { enum VizPanel { Scope, Spectrum, + Lissajous, Preview, } @@ -142,11 +156,13 @@ fn render_viz_area( let is_vertical_layout = matches!(app.audio.config.layout, MainLayout::Left | MainLayout::Right); let show_scope = app.audio.config.show_scope; let show_spectrum = app.audio.config.show_spectrum; + let show_lissajous = app.audio.config.show_lissajous; let show_preview = app.audio.config.show_preview; let mut panels = Vec::new(); if show_scope { panels.push(VizPanel::Scope); } if show_spectrum { panels.push(VizPanel::Spectrum); } + if show_lissajous { panels.push(VizPanel::Lissajous); } if show_preview { panels.push(VizPanel::Preview); } let constraints: Vec = panels.iter().map(|_| Constraint::Fill(1)).collect(); @@ -173,6 +189,7 @@ fn render_viz_area( match panel { VizPanel::Scope => render_scope(frame, app, *panel_area, orientation), VizPanel::Spectrum => render_spectrum(frame, app, *panel_area), + VizPanel::Lissajous => render_lissajous(frame, app, *panel_area), VizPanel::Preview => { let user_words = user_words_once.as_ref().unwrap(); let has_prelude = !app.project_state.project.prelude.trim().is_empty(); @@ -491,6 +508,19 @@ fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(spectrum, inner); } +fn render_lissajous(frame: &mut Frame, app: &App, area: Rect) { + let theme = theme::get(); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::new().fg(theme.ui.border)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let lissajous = Lissajous::new(&app.metrics.scope, &app.metrics.scope_right) + .color(theme.meter.low); + frame.render_widget(lissajous, inner); +} + fn render_script_preview( frame: &mut Frame, app: &App, diff --git a/src/views/options_view.rs b/src/views/options_view.rs index d112c9b..87f020d 100644 --- a/src/views/options_view.rs +++ b/src/views/options_view.rs @@ -78,6 +78,16 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { focus == OptionsFocus::ShowSpectrum, &theme, ), + render_option_line( + "Show lissajous", + if app.audio.config.show_lissajous { + "On" + } else { + "Off" + }, + focus == OptionsFocus::ShowLissajous, + &theme, + ), render_option_line( "Completion", if app.ui.show_completion { "On" } else { "Off" }, @@ -346,6 +356,7 @@ fn option_description(focus: OptionsFocus) -> Option<&'static str> { OptionsFocus::RuntimeHighlight => Some("Highlight executed code spans during playback"), OptionsFocus::ShowScope => Some("Oscilloscope on the engine page"), OptionsFocus::ShowSpectrum => Some("Spectrum analyzer on the engine page"), + OptionsFocus::ShowLissajous => Some("XY stereo phase scope (left vs right)"), OptionsFocus::ShowCompletion => Some("Word completion popup in the editor"), OptionsFocus::ShowPreview => Some("Step script preview on the sequencer grid"), OptionsFocus::PerformanceMode => Some("Hide header and footer bars"), diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 062975e..29aebd3 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -1,6 +1,6 @@ pub use cagire_ratatui::{ hint_line, render_props_form, render_scroll_indicators, render_search_bar, render_section_header, CategoryItem, CategoryList, ConfirmModal, FileBrowserModal, - IndicatorAlign, ModalFrame, NavMinimap, NavTile, Orientation, SampleBrowser, Scope, Selection, - Spectrum, TextInputModal, VuMeter, Waveform, + IndicatorAlign, Lissajous, ModalFrame, NavMinimap, NavTile, Orientation, SampleBrowser, Scope, + Selection, Spectrum, TextInputModal, VuMeter, Waveform, };