From 0ecc4dae1143af47e2f8224a1f9ebf7822e161e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Thu, 26 Feb 2026 23:29:07 +0100 Subject: [PATCH] Feat: UI / UX improvements once more (mouse) --- .github/workflows/pages.yml | 1 + .github/workflows/release.yml | 5 +- crates/ratatui/src/lib.rs | 2 +- crates/ratatui/src/lissajous.rs | 157 +++++++++++++++++++-- crates/ratatui/src/spectrum.rs | 241 ++++++++++++++++++++++++++------ src/app/dispatch.rs | 5 + src/commands.rs | 5 + src/input/mouse.rs | 210 +++++++++++++++++++++++++--- src/state/audio.rs | 44 ++++++ src/state/mod.rs | 2 +- src/state/ui.rs | 2 + src/views/engine_view.rs | 41 ++++-- src/views/main_view.rs | 41 ++++-- src/views/render.rs | 15 +- src/widgets/mod.rs | 2 +- tests/forth/sound.rs | 6 +- 16 files changed, 680 insertions(+), 99 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 5644352..102b94b 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -16,6 +16,7 @@ concurrency: jobs: deploy: + if: github.server_url == 'https://github.com' environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7915fde..5935ebc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,7 @@ concurrency: jobs: build: + if: github.server_url == 'https://github.com' strategy: fail-fast: false matrix: @@ -153,6 +154,7 @@ jobs: path: target/bundled/cagire-plugins.vst3 build-cross: + if: github.server_url == 'https://github.com' runs-on: ubuntu-latest timeout-minutes: 45 @@ -200,6 +202,7 @@ jobs: path: target/${{ matrix.target }}/release/cagire-desktop universal-macos: + if: github.server_url == 'https://github.com' needs: build runs-on: macos-14 timeout-minutes: 10 @@ -308,7 +311,7 @@ jobs: release: needs: [build, build-cross, universal-macos] - if: startsWith(github.ref, 'refs/tags/v') + if: startsWith(github.ref, 'refs/tags/v') && github.server_url == 'https://github.com' runs-on: ubuntu-latest timeout-minutes: 10 permissions: diff --git a/crates/ratatui/src/lib.rs b/crates/ratatui/src/lib.rs index 5034f45..ec9af39 100644 --- a/crates/ratatui/src/lib.rs +++ b/crates/ratatui/src/lib.rs @@ -38,7 +38,7 @@ pub use scroll_indicators::{render_scroll_indicators, IndicatorAlign}; pub use search_bar::render_search_bar; pub use section_header::render_section_header; pub use sparkles::Sparkles; -pub use spectrum::Spectrum; +pub use spectrum::{Spectrum, SpectrumStyle}; pub use text_input::TextInputModal; pub use vu_meter::VuMeter; pub use waveform::Waveform; diff --git a/crates/ratatui/src/lissajous.rs b/crates/ratatui/src/lissajous.rs index c04f5dc..13b48ff 100644 --- a/crates/ratatui/src/lissajous.rs +++ b/crates/ratatui/src/lissajous.rs @@ -9,6 +9,13 @@ use std::cell::RefCell; thread_local! { static PATTERNS: RefCell> = const { RefCell::new(Vec::new()) }; + static TRAIL: RefCell = const { RefCell::new(TrailState { fine_w: 0, fine_h: 0, heat: Vec::new() }) }; +} + +struct TrailState { + fine_w: usize, + fine_h: usize, + heat: Vec, } /// XY oscilloscope plotting left vs right channels as a Lissajous curve. @@ -17,6 +24,7 @@ pub struct Lissajous<'a> { right: &'a [f32], color: Option, gain: f32, + trails: bool, } impl<'a> Lissajous<'a> { @@ -26,9 +34,15 @@ impl<'a> Lissajous<'a> { right, color: None, gain: 1.0, + trails: false, } } + pub fn trails(mut self, enabled: bool) -> Self { + self.trails = enabled; + self + } + pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self @@ -46,6 +60,16 @@ impl Widget for Lissajous<'_> { return; } + if self.trails { + self.render_trails(area, buf); + } else { + self.render_normal(area, buf); + } + } +} + +impl Lissajous<'_> { + fn render_normal(self, area: Rect, buf: &mut Buffer) { let color = self.color.unwrap_or_else(|| theme::get().meter.low); let width = area.width as usize; let height = area.height as usize; @@ -63,7 +87,6 @@ impl Widget for Lissajous<'_> { let l = (self.left[i] * self.gain).clamp(-1.0, 1.0); let r = (self.right[i] * self.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); @@ -74,19 +97,7 @@ impl Widget for Lissajous<'_> { 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; + patterns[char_y * width + char_x] |= braille_bit(dot_x, dot_y); } for cy in 0..height { @@ -102,4 +113,122 @@ impl Widget for Lissajous<'_> { } }); } + + fn render_trails(self, area: Rect, buf: &mut Buffer) { + let theme = theme::get(); + let width = area.width as usize; + let height = area.height as usize; + let fine_w = width * 2; + let fine_h = height * 4; + let len = self.left.len().min(self.right.len()); + + TRAIL.with(|t| { + let mut trail = t.borrow_mut(); + + // Reset if dimensions changed + if trail.fine_w != fine_w || trail.fine_h != fine_h { + trail.fine_w = fine_w; + trail.fine_h = fine_h; + trail.heat.clear(); + trail.heat.resize(fine_w * fine_h, 0.0); + } + + // Decay existing heat + for h in trail.heat.iter_mut() { + *h *= 0.85; + } + + // Plot new sample points + for i in 0..len { + let l = (self.left[i] * self.gain).clamp(-1.0, 1.0); + let r = (self.right[i] * self.gain).clamp(-1.0, 1.0); + + let fx = ((r + 1.0) * 0.5 * (fine_w - 1) as f32).round() as usize; + let fy = ((1.0 - l) * 0.5 * (fine_h - 1) as f32).round() as usize; + let fx = fx.min(fine_w - 1); + let fy = fy.min(fine_h - 1); + + trail.heat[fy * fine_w + fx] = 1.0; + } + + // Convert heat map to braille + PATTERNS.with(|p| { + let mut patterns = p.borrow_mut(); + patterns.clear(); + patterns.resize(width * height, 0); + + // Track brightest color per cell + let mut colors: Vec> = vec![None; width * height]; + + for fy in 0..fine_h { + for fx in 0..fine_w { + let h = trail.heat[fy * fine_w + fx]; + if h < 0.05 { + continue; + } + + let cx = fx / 2; + let cy = fy / 4; + let dx = fx % 2; + let dy = fy % 4; + + let idx = cy * width + cx; + patterns[idx] |= braille_bit(dx, dy); + + let dot_color = if h > 0.7 { + theme.meter.high + } else if h > 0.25 { + theme.meter.mid + } else { + theme.meter.low + }; + + let replace = match colors[idx] { + None => true, + Some(cur) => { + rank_color(dot_color, &theme) > rank_color(cur, &theme) + } + }; + if replace { + colors[idx] = Some(dot_color); + } + } + } + + for cy in 0..height { + for cx in 0..width { + let idx = cy * width + cx; + let pattern = patterns[idx]; + if pattern != 0 { + let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' '); + let color = colors[idx].unwrap_or(theme.meter.low); + buf[(area.x + cx as u16, area.y + cy as u16)] + .set_char(ch) + .set_fg(color); + } + } + } + }); + }); + } +} + +fn braille_bit(dot_x: usize, dot_y: usize) -> u8 { + 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!(), + } +} + +fn rank_color(c: Color, theme: &crate::theme::ThemeColors) -> u8 { + if c == theme.meter.high { 2 } + else if c == theme.meter.mid { 1 } + else { 0 } } diff --git a/crates/ratatui/src/spectrum.rs b/crates/ratatui/src/spectrum.rs index c60cce1..471c718 100644 --- a/crates/ratatui/src/spectrum.rs +++ b/crates/ratatui/src/spectrum.rs @@ -1,28 +1,59 @@ -//! 32-band frequency spectrum bar display. +//! 32-band frequency spectrum display with optional peak hold. use crate::theme; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::widgets::Widget; +use std::cell::RefCell; const BLOCKS: [char; 8] = ['\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}']; +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum SpectrumStyle { + #[default] + Bars, + Line, + Filled, +} + +thread_local! { + static PEAKS: RefCell<[f32; 32]> = const { RefCell::new([0.0; 32]) }; + static PATTERNS: RefCell> = const { RefCell::new(Vec::new()) }; +} + /// 32-band spectrum analyzer using block characters. pub struct Spectrum<'a> { data: &'a [f32; 32], gain: f32, + style: SpectrumStyle, + peaks: bool, } impl<'a> Spectrum<'a> { pub fn new(data: &'a [f32; 32]) -> Self { - Self { data, gain: 1.0 } + Self { + data, + gain: 1.0, + style: SpectrumStyle::Bars, + peaks: false, + } } pub fn gain(mut self, g: f32) -> Self { self.gain = g; self } + + pub fn style(mut self, s: SpectrumStyle) -> Self { + self.style = s; + self + } + + pub fn peaks(mut self, enabled: bool) -> Self { + self.peaks = enabled; + self + } } impl Widget for Spectrum<'_> { @@ -31,45 +62,177 @@ impl Widget for Spectrum<'_> { return; } - let colors = theme::get(); - let height = area.height as f32; - let base = area.width as usize / 32; - let remainder = area.width as usize % 32; - if base == 0 && remainder == 0 { - return; - } - - let mut x_start = area.x; - for (band, &mag) in self.data.iter().enumerate() { - let w = base + if band < remainder { 1 } else { 0 }; - if w == 0 { - continue; - } - let bar_height = (mag * self.gain).min(1.0) * height; - let full_cells = bar_height as usize; - let frac = bar_height - full_cells as f32; - let frac_idx = (frac * 8.0) as usize; - - for row in 0..area.height as usize { - let y = area.y + area.height - 1 - row as u16; - let ratio = row as f32 / area.height as f32; - let color = if ratio < 0.33 { - Color::Rgb(colors.meter.low_rgb.0, colors.meter.low_rgb.1, colors.meter.low_rgb.2) - } else if ratio < 0.66 { - Color::Rgb(colors.meter.mid_rgb.0, colors.meter.mid_rgb.1, colors.meter.mid_rgb.2) - } else { - Color::Rgb(colors.meter.high_rgb.0, colors.meter.high_rgb.1, colors.meter.high_rgb.2) - }; - for dx in 0..w as u16 { - let x = x_start + dx; - if row < full_cells { - buf[(x, y)].set_char(BLOCKS[7]).set_fg(color); - } else if row == full_cells && frac_idx > 0 { - buf[(x, y)].set_char(BLOCKS[frac_idx - 1]).set_fg(color); + // Update peak hold state + let peak_values = if self.peaks { + Some(PEAKS.with(|p| { + let mut peaks = p.borrow_mut(); + for (i, &mag) in self.data.iter().enumerate() { + let v = (mag * self.gain).min(1.0); + if v >= peaks[i] { + peaks[i] = v; + } else { + peaks[i] = (peaks[i] - 0.02).max(v); } } - } - x_start += w as u16; + *peaks + })) + } else { + None + }; + + match self.style { + SpectrumStyle::Bars => render_bars(self.data, area, buf, self.gain, peak_values.as_ref()), + SpectrumStyle::Line => render_braille(self.data, area, buf, self.gain, false, peak_values.as_ref()), + SpectrumStyle::Filled => render_braille(self.data, area, buf, self.gain, true, peak_values.as_ref()), } } } + +fn band_color(ratio: f32, colors: &theme::ThemeColors) -> Color { + if ratio < 0.33 { + Color::Rgb(colors.meter.low_rgb.0, colors.meter.low_rgb.1, colors.meter.low_rgb.2) + } else if ratio < 0.66 { + Color::Rgb(colors.meter.mid_rgb.0, colors.meter.mid_rgb.1, colors.meter.mid_rgb.2) + } else { + Color::Rgb(colors.meter.high_rgb.0, colors.meter.high_rgb.1, colors.meter.high_rgb.2) + } +} + +fn render_bars(data: &[f32; 32], area: Rect, buf: &mut Buffer, gain: f32, peaks: Option<&[f32; 32]>) { + let colors = theme::get(); + let height = area.height as f32; + let base = area.width as usize / 32; + let remainder = area.width as usize % 32; + if base == 0 && remainder == 0 { + return; + } + + let mut x_start = area.x; + for (band, &mag) in data.iter().enumerate() { + let w = base + if band < remainder { 1 } else { 0 }; + if w == 0 { + continue; + } + let bar_height = (mag * gain).min(1.0) * height; + let full_cells = bar_height as usize; + let frac = bar_height - full_cells as f32; + let frac_idx = (frac * 8.0) as usize; + + // Peak hold row + let peak_row = peaks.map(|p| { + let ph = p[band] * height; + let row = (height - ph).max(0.0) as usize; + row.min(area.height as usize - 1) + }); + + for row in 0..area.height as usize { + let y = area.y + area.height - 1 - row as u16; + let ratio = row as f32 / area.height as f32; + let color = band_color(ratio, &colors); + + for dx in 0..w as u16 { + let x = x_start + dx; + if row < full_cells { + buf[(x, y)].set_char(BLOCKS[7]).set_fg(color); + } else if row == full_cells && frac_idx > 0 { + buf[(x, y)].set_char(BLOCKS[frac_idx - 1]).set_fg(color); + } else if let Some(pr) = peak_row { + // peak_row is from top (0 = top), row is from bottom + let from_top = area.height as usize - 1 - row; + if from_top == pr { + buf[(x, y)].set_char('─').set_fg(colors.meter.high); + } + } + } + } + x_start += w as u16; + } +} + +fn render_braille( + data: &[f32; 32], + area: Rect, + buf: &mut Buffer, + gain: f32, + filled: bool, + peaks: Option<&[f32; 32]>, +) { + let colors = theme::get(); + let width = area.width as usize; + let height = area.height as usize; + let fine_w = width * 2; + let fine_h = height * 4; + + PATTERNS.with(|p| { + let mut patterns = p.borrow_mut(); + patterns.clear(); + patterns.resize(width * height, 0); + + // Interpolate 32 bands across fine_w columns + for fx in 0..fine_w { + let band_f = fx as f32 * 31.0 / (fine_w - 1).max(1) as f32; + let lo = band_f as usize; + let hi = (lo + 1).min(31); + let t = band_f - lo as f32; + let mag = ((data[lo] * (1.0 - t) + data[hi] * t) * gain).min(1.0); + let fy = ((1.0 - mag) * (fine_h - 1) as f32).round() as usize; + let fy = fy.min(fine_h - 1); + + if filled { + for y in fy..fine_h { + let cy = y / 4; + let dy = y % 4; + let cx = fx / 2; + let dx = fx % 2; + patterns[cy * width + cx] |= braille_bit(dx, dy); + } + } else { + let cy = fy / 4; + let dy = fy % 4; + let cx = fx / 2; + let dx = fx % 2; + patterns[cy * width + cx] |= braille_bit(dx, dy); + } + + // Peak dots + if let Some(pk) = peaks { + let pv = (pk[lo] * (1.0 - t) + pk[hi] * t).min(1.0); + let py = ((1.0 - pv) * (fine_h - 1) as f32).round() as usize; + let py = py.min(fine_h - 1); + let cy = py / 4; + let dy = py % 4; + let cx = fx / 2; + let dx = fx % 2; + patterns[cy * width + cx] |= braille_bit(dx, dy); + } + } + + for cy in 0..height { + for cx in 0..width { + let pattern = patterns[cy * width + cx]; + if pattern != 0 { + let ratio = 1.0 - (cy as f32 / height as f32); + let color = band_color(ratio, &colors); + 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); + } + } + } + }); +} + +fn braille_bit(dot_x: usize, dot_y: usize) -> u8 { + 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!(), + } +} diff --git a/src/app/dispatch.rs b/src/app/dispatch.rs index a72e748..5d9e468 100644 --- a/src/app/dispatch.rs +++ b/src/app/dispatch.rs @@ -418,6 +418,11 @@ impl App { 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::CycleScopeMode => self.audio.config.scope_mode = self.audio.config.scope_mode.toggle(), + AppCommand::FlipScopeOrientation => self.audio.config.scope_vertical = !self.audio.config.scope_vertical, + AppCommand::ToggleLissajousTrails => self.audio.config.lissajous_trails = !self.audio.config.lissajous_trails, + AppCommand::CycleSpectrumMode => self.audio.config.spectrum_mode = self.audio.config.spectrum_mode.cycle(), + AppCommand::ToggleSpectrumPeaks => self.audio.config.spectrum_peaks = !self.audio.config.spectrum_peaks, AppCommand::TogglePreview => self.audio.config.show_preview = !self.audio.config.show_preview, AppCommand::SetGainBoost(g) => self.audio.config.gain_boost = g, AppCommand::ToggleNormalizeViz => self.audio.config.normalize_viz = !self.audio.config.normalize_viz, diff --git a/src/commands.rs b/src/commands.rs index e4b31f1..abd8cee 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -278,6 +278,11 @@ pub enum AppCommand { ToggleScope, ToggleSpectrum, ToggleLissajous, + CycleScopeMode, + FlipScopeOrientation, + ToggleLissajousTrails, + CycleSpectrumMode, + ToggleSpectrumPeaks, TogglePreview, SetGainBoost(f32), ToggleNormalizeViz, diff --git a/src/input/mouse.rs b/src/input/mouse.rs index 892c5c4..657ab62 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -1,3 +1,5 @@ +use std::time::Instant; + use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::{Constraint, Layout, Rect}; @@ -11,6 +13,14 @@ use crate::views::{dict_view, engine_view, help_view, main_view, patterns_view, use super::InputContext; +#[derive(Clone, Copy, PartialEq, Eq)] +enum ClickKind { + Single, + Double, +} + +const DOUBLE_CLICK_MS: u128 = 300; + pub fn handle_mouse(ctx: &mut InputContext, mouse: MouseEvent, term: Rect) { let kind = mouse.kind; let col = mouse.column; @@ -25,7 +35,18 @@ pub fn handle_mouse(ctx: &mut InputContext, mouse: MouseEvent, term: Rect) { } match kind { - MouseEventKind::Down(MouseButton::Left) => handle_click(ctx, col, row, term), + MouseEventKind::Down(MouseButton::Left) => { + let now = Instant::now(); + let click_kind = match ctx.app.ui.last_click.take() { + Some((t, c, r)) if now.duration_since(t).as_millis() < DOUBLE_CLICK_MS + && c == col && r == row => ClickKind::Double, + _ => { + ctx.app.ui.last_click = Some((now, col, row)); + ClickKind::Single + } + }; + handle_click(ctx, col, row, term, click_kind); + } MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Moved => { handle_editor_drag(ctx, col, row, term); handle_script_editor_drag(ctx, col, row, term); @@ -116,7 +137,7 @@ fn handle_editor_mouse(ctx: &mut InputContext, col: u16, row: u16, term: Rect, d .move_cursor_to(text_row, text_col); } -fn handle_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) { +fn handle_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect, kind: ClickKind) { // Sticky minimap intercepts all clicks if matches!(ctx.app.ui.minimap, MinimapMode::Sticky) { if let Some((gc, gr)) = cagire_ratatui::hit_test_tile(col, row, term) { @@ -144,7 +165,7 @@ fn handle_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) { } else if contains(footer, col, row) { handle_footer_click(ctx, col, row, footer); } else if contains(body, col, row) { - handle_body_click(ctx, col, row, body); + handle_body_click(ctx, col, row, body, kind); } } @@ -279,19 +300,30 @@ fn handle_scroll(ctx: &mut InputContext, col: u16, row: u16, term: Rect, up: boo // --- Header --- -fn handle_header_click(ctx: &mut InputContext, col: u16, _row: u16, header: Rect) { - let [transport_area, _live, _tempo, _bank, _pattern, _stats] = Layout::horizontal([ - Constraint::Min(12), - Constraint::Length(9), - Constraint::Min(14), - Constraint::Fill(1), - Constraint::Fill(2), - Constraint::Min(20), - ]) - .areas(header); +fn handle_header_click(ctx: &mut InputContext, col: u16, row: u16, header: Rect) { + let [logo_area, transport_area, _live, tempo_area, _bank, pattern_area, stats_area] = + Layout::horizontal([ + Constraint::Length(5), + Constraint::Min(12), + Constraint::Length(9), + Constraint::Min(14), + Constraint::Fill(1), + Constraint::Fill(2), + Constraint::Min(20), + ]) + .areas(header); - if contains(transport_area, col, _row) { + if contains(logo_area, col, row) { + ctx.app.ui.minimap = MinimapMode::Sticky; + } else if contains(transport_area, col, row) { ctx.dispatch(AppCommand::TogglePlaying); + } else if contains(tempo_area, col, row) { + let tempo = format!("{:.1}", ctx.link.tempo()); + ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(tempo))); + } else if contains(pattern_area, col, row) { + ctx.dispatch(AppCommand::GoToPage(Page::Patterns)); + } else if contains(stats_area, col, row) { + ctx.dispatch(AppCommand::GoToPage(Page::Engine)); } } @@ -325,7 +357,7 @@ fn handle_footer_click(ctx: &mut InputContext, col: u16, row: u16, footer: Rect) // --- Body --- -fn handle_body_click(ctx: &mut InputContext, col: u16, row: u16, body: Rect) { +fn handle_body_click(ctx: &mut InputContext, col: u16, row: u16, body: Rect, kind: ClickKind) { // Account for side panel splitting let page_area = if ctx.app.panel.visible && ctx.app.panel.side.is_some() { if body.width >= 120 { @@ -350,25 +382,31 @@ fn handle_body_click(ctx: &mut InputContext, col: u16, row: u16, body: Rect) { } match ctx.app.page { - Page::Main => handle_main_click(ctx, col, row, page_area), - Page::Patterns => handle_patterns_click(ctx, col, row, page_area), + Page::Main => handle_main_click(ctx, col, row, page_area, kind), + Page::Patterns => handle_patterns_click(ctx, col, row, page_area, kind), Page::Help => handle_help_click(ctx, col, row, page_area), Page::Dict => handle_dict_click(ctx, col, row, page_area), Page::Options => handle_options_click(ctx, col, row, page_area), - Page::Engine => handle_engine_click(ctx, col, row, page_area), + Page::Engine => handle_engine_click(ctx, col, row, page_area, kind), Page::Script => handle_script_click(ctx, col, row, page_area), } } // --- Main page (grid) --- -fn handle_main_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) { +fn handle_main_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect, kind: ClickKind) { let [main_area, _, _vu_area] = main_view::layout(area); if !contains(main_area, col, row) { return; } + // Check viz area clicks before sequencer + if let Some(cmd) = hit_test_main_viz(ctx, col, row, main_area, kind) { + ctx.dispatch(cmd); + return; + } + let sequencer_area = main_view::sequencer_rect(ctx.app, main_area); if !contains(sequencer_area, col, row) { @@ -377,9 +415,105 @@ fn handle_main_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) { if let Some(step) = hit_test_grid(ctx, col, row, sequencer_area) { ctx.dispatch(AppCommand::GoToStep(step)); + if kind == ClickKind::Double { + ctx.dispatch(AppCommand::OpenModal(Modal::Editor)); + } } } +fn hit_test_main_viz( + ctx: &InputContext, + col: u16, + row: u16, + main_area: Rect, + kind: ClickKind, +) -> Option { + use crate::state::MainLayout; + + let layout = ctx.app.audio.config.layout; + let show_scope = ctx.app.audio.config.show_scope; + let show_spectrum = ctx.app.audio.config.show_spectrum; + let show_lissajous = ctx.app.audio.config.show_lissajous; + let show_preview = ctx.app.audio.config.show_preview; + + let has_viz = show_scope || show_spectrum || show_lissajous || show_preview; + if !has_viz { + return None; + } + + // Determine viz area based on layout + let viz_area = if matches!(layout, MainLayout::Top) { + // Top layout: render_audio_viz uses only audio panels (no preview) + let has_audio_viz = show_scope || show_spectrum || show_lissajous; + if !has_audio_viz { + return None; + } + let mut constraints = Vec::new(); + if has_audio_viz { + constraints.push(Constraint::Fill(1)); + } + if show_preview { + let ph = if has_audio_viz { 10u16 } else { 14 }; + constraints.push(Constraint::Length(ph)); + } + constraints.push(Constraint::Fill(1)); + let areas = Layout::vertical(&constraints).split(main_area); + areas[0] + } else { + let (viz, _) = main_view::viz_seq_split(main_area, layout, has_viz); + viz + }; + + if !contains(viz_area, col, row) { + return None; + } + + // Build panel list matching render order + let is_vertical_layout = matches!(layout, MainLayout::Left | MainLayout::Right); + let mut panels: Vec<&str> = Vec::new(); + if show_scope { panels.push("scope"); } + if show_spectrum { panels.push("spectrum"); } + if show_lissajous { panels.push("lissajous"); } + + // Top layout uses render_audio_viz (horizontal only, no preview) + // Other layouts use render_viz_area (includes preview, vertical if Left/Right) + if !matches!(layout, MainLayout::Top) && show_preview { + panels.push("preview"); + } + + if panels.is_empty() { + return None; + } + + let constraints: Vec = panels.iter().map(|_| Constraint::Fill(1)).collect(); + let areas: Vec = if is_vertical_layout && !matches!(layout, MainLayout::Top) { + Layout::vertical(&constraints).split(viz_area).to_vec() + } else { + Layout::horizontal(&constraints).split(viz_area).to_vec() + }; + + for (panel, panel_area) in panels.iter().zip(areas.iter()) { + if contains(*panel_area, col, row) { + return match *panel { + "scope" => Some(if kind == ClickKind::Double { + AppCommand::FlipScopeOrientation + } else { + AppCommand::CycleScopeMode + }), + "lissajous" => Some(AppCommand::ToggleLissajousTrails), + "spectrum" => Some(if kind == ClickKind::Double { + AppCommand::ToggleSpectrumPeaks + } else { + AppCommand::CycleSpectrumMode + }), + _ => None, + }; + } + } + + None +} + fn hit_test_grid(ctx: &InputContext, col: u16, row: u16, area: Rect) -> Option { let pattern = ctx.app.current_edit_pattern(); let length = pattern.length; @@ -402,7 +536,7 @@ fn hit_test_grid(ctx: &InputContext, col: u16, row: u16, area: Rect) -> Option Self { + match self { + Self::Line => Self::Filled, + Self::Filled => Self::Line, + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum SpectrumMode { + #[default] + Bars, + Line, + Filled, +} + +impl SpectrumMode { + pub fn cycle(self) -> Self { + match self { + Self::Bars => Self::Line, + Self::Line => Self::Filled, + Self::Filled => Self::Bars, + } + } +} + #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum RefreshRate { #[default] @@ -88,6 +122,11 @@ pub struct AudioConfig { pub gain_boost: f32, pub normalize_viz: bool, pub layout: MainLayout, + pub scope_mode: ScopeMode, + pub scope_vertical: bool, + pub lissajous_trails: bool, + pub spectrum_mode: SpectrumMode, + pub spectrum_peaks: bool, } impl Default for AudioConfig { @@ -110,6 +149,11 @@ impl Default for AudioConfig { gain_boost: 1.0, normalize_viz: false, layout: MainLayout::default(), + scope_mode: ScopeMode::default(), + scope_vertical: false, + lissajous_trails: false, + spectrum_mode: SpectrumMode::default(), + spectrum_peaks: false, } } } diff --git a/src/state/mod.rs b/src/state/mod.rs index 40724a7..a22590a 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -30,7 +30,7 @@ pub mod sample_browser; pub mod undo; pub mod ui; -pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, SettingKind}; +pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, ScopeMode, SettingKind, SpectrumMode}; pub use color_scheme::ColorScheme; pub use editor::{ CopiedStepData, CopiedSteps, EditorContext, EditorTarget, EuclideanField, PatternField, diff --git a/src/state/ui.rs b/src/state/ui.rs index 1bdcefe..ced6fdc 100644 --- a/src/state/ui.rs +++ b/src/state/ui.rs @@ -85,6 +85,7 @@ pub struct UiState { pub demo_index: usize, pub nav_indicator_until: Option, pub nav_fx: RefCell>, + pub last_click: Option<(Instant, u16, u16)>, } impl Default for UiState { @@ -139,6 +140,7 @@ impl Default for UiState { demo_index: 0, nav_indicator_until: None, nav_fx: RefCell::new(None), + last_click: None, } } } diff --git a/src/views/engine_view.rs b/src/views/engine_view.rs index 09ad606..570316d 100644 --- a/src/views/engine_view.rs +++ b/src/views/engine_view.rs @@ -8,9 +8,10 @@ use ratatui::Frame; use crate::app::App; use crate::state::{DeviceKind, EngineSection, SettingKind}; use crate::theme; +use crate::state::{ScopeMode, SpectrumMode}; use crate::widgets::{ render_scroll_indicators, render_section_header, IndicatorAlign, Lissajous, Orientation, Scope, - Spectrum, + Spectrum, SpectrumStyle, Waveform, }; pub fn layout(area: Rect) -> [Rect; 3] { @@ -182,12 +183,28 @@ fn render_scope(frame: &mut Frame, app: &App, area: Rect) { let inner = block.inner(area); frame.render_widget(block, area); + let orientation = if app.audio.config.scope_vertical { + Orientation::Vertical + } else { + Orientation::Horizontal + }; let gain = viz_gain(&app.metrics.scope, &app.audio.config); - let scope = Scope::new(&app.metrics.scope) - .orientation(Orientation::Horizontal) - .color(theme.meter.low) - .gain(gain); - frame.render_widget(scope, inner); + match app.audio.config.scope_mode { + ScopeMode::Line => { + let scope = Scope::new(&app.metrics.scope) + .orientation(orientation) + .color(theme.meter.low) + .gain(gain); + frame.render_widget(scope, inner); + } + ScopeMode::Filled => { + let waveform = Waveform::new(&app.metrics.scope) + .orientation(orientation) + .color(theme.meter.low) + .gain(gain); + frame.render_widget(waveform, inner); + } + } } fn render_lissajous(frame: &mut Frame, app: &App, area: Rect) { @@ -209,7 +226,8 @@ fn render_lissajous(frame: &mut Frame, app: &App, area: Rect) { }; let lissajous = Lissajous::new(&app.metrics.scope, &app.metrics.scope_right) .color(theme.meter.low) - .gain(gain); + .gain(gain) + .trails(app.audio.config.lissajous_trails); frame.render_widget(lissajous, inner); } @@ -228,8 +246,15 @@ fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) { } else { 1.0 }; + let style = match app.audio.config.spectrum_mode { + SpectrumMode::Bars => SpectrumStyle::Bars, + SpectrumMode::Line => SpectrumStyle::Line, + SpectrumMode::Filled => SpectrumStyle::Filled, + }; let spectrum = Spectrum::new(&app.metrics.spectrum) - .gain(gain); + .gain(gain) + .style(style) + .peaks(app.audio.config.spectrum_peaks); frame.render_widget(spectrum, inner); } diff --git a/src/views/main_view.rs b/src/views/main_view.rs index 08adb79..9b775a9 100644 --- a/src/views/main_view.rs +++ b/src/views/main_view.rs @@ -12,7 +12,8 @@ use crate::engine::SequencerSnapshot; use crate::state::MainLayout; use crate::theme; use crate::views::render::highlight_script_lines; -use crate::widgets::{Lissajous, Orientation, Scope, Spectrum, VuMeter}; +use crate::state::{ScopeMode, SpectrumMode}; +use crate::widgets::{Lissajous, Orientation, Scope, Spectrum, SpectrumStyle, VuMeter, Waveform}; pub fn layout(area: Rect) -> [Rect; 3] { Layout::horizontal([ @@ -499,12 +500,28 @@ pub(crate) fn render_scope(frame: &mut Frame, app: &App, area: Rect, orientation let inner = block.inner(area); frame.render_widget(block, area); + let orientation = if app.audio.config.scope_vertical { + Orientation::Vertical + } else { + orientation + }; let gain = viz_gain(&app.metrics.scope, &app.audio.config); - let scope = Scope::new(&app.metrics.scope) - .orientation(orientation) - .color(theme.meter.low) - .gain(gain); - frame.render_widget(scope, inner); + match app.audio.config.scope_mode { + ScopeMode::Line => { + let scope = Scope::new(&app.metrics.scope) + .orientation(orientation) + .color(theme.meter.low) + .gain(gain); + frame.render_widget(scope, inner); + } + ScopeMode::Filled => { + let waveform = Waveform::new(&app.metrics.scope) + .orientation(orientation) + .color(theme.meter.low) + .gain(gain); + frame.render_widget(waveform, inner); + } + } } pub(crate) fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) { @@ -520,8 +537,15 @@ pub(crate) fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) { } else { 1.0 }; + let style = match app.audio.config.spectrum_mode { + SpectrumMode::Bars => SpectrumStyle::Bars, + SpectrumMode::Line => SpectrumStyle::Line, + SpectrumMode::Filled => SpectrumStyle::Filled, + }; let spectrum = Spectrum::new(&app.metrics.spectrum) - .gain(gain); + .gain(gain) + .style(style) + .peaks(app.audio.config.spectrum_peaks); frame.render_widget(spectrum, inner); } @@ -542,7 +566,8 @@ pub(crate) fn render_lissajous(frame: &mut Frame, app: &App, area: Rect) { }; let lissajous = Lissajous::new(&app.metrics.scope, &app.metrics.scope_right) .color(theme.meter.low) - .gain(gain); + .gain(gain) + .trails(app.audio.config.lissajous_trails); frame.render_widget(lissajous, inner); } diff --git a/src/views/render.rs b/src/views/render.rs index 120006e..d40af42 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -371,8 +371,9 @@ fn render_header( let pad = Padding::vertical(1); - let [transport_area, live_area, tempo_area, bank_area, pattern_area, stats_area] = + let [logo_area, transport_area, live_area, tempo_area, bank_area, pattern_area, stats_area] = Layout::horizontal([ + Constraint::Length(5), Constraint::Min(12), Constraint::Length(9), Constraint::Min(14), @@ -382,6 +383,18 @@ fn render_header( ]) .areas(area); + // Logo + let logo_style = Style::new() + .bg(theme.header.bank_bg) + .fg(theme.ui.accent) + .add_modifier(Modifier::BOLD); + frame.render_widget( + Paragraph::new("\u{28ff}") + .block(Block::default().padding(pad).style(logo_style)) + .alignment(Alignment::Center), + logo_area, + ); + // Transport block let (transport_bg, transport_text) = if app.playback.playing { (theme.status.playing_bg, " ▶ PLAYING ") diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 29aebd3..0c39878 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -2,5 +2,5 @@ pub use cagire_ratatui::{ hint_line, render_props_form, render_scroll_indicators, render_search_bar, render_section_header, CategoryItem, CategoryList, ConfirmModal, FileBrowserModal, IndicatorAlign, Lissajous, ModalFrame, NavMinimap, NavTile, Orientation, SampleBrowser, Scope, - Selection, Spectrum, TextInputModal, VuMeter, Waveform, + Selection, Spectrum, SpectrumStyle, TextInputModal, VuMeter, Waveform, }; diff --git a/tests/forth/sound.rs b/tests/forth/sound.rs index d5e1503..265453c 100644 --- a/tests/forth/sound.rs +++ b/tests/forth/sound.rs @@ -230,19 +230,19 @@ fn noall_clears_across_evaluations() { #[test] fn rec() { let outputs = expect_outputs(r#""loop1" rec"#, 1); - assert_eq!(outputs[0], "/rec/rec/sound/loop1"); + assert_eq!(outputs[0], "/doux/rec/sound/loop1"); } #[test] fn overdub() { let outputs = expect_outputs(r#""loop1" overdub"#, 1); - assert_eq!(outputs[0], "/rec/rec/sound/loop1/overdub/1"); + assert_eq!(outputs[0], "/doux/rec/sound/loop1/overdub/1"); } #[test] fn overdub_alias_dub() { let outputs = expect_outputs(r#""loop1" dub"#, 1); - assert_eq!(outputs[0], "/rec/rec/sound/loop1/overdub/1"); + assert_eq!(outputs[0], "/doux/rec/sound/loop1/overdub/1"); } #[test]