Feat: UI / UX improvements once more (mouse)
Some checks failed
CI / check (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Successful in 10m3s
Deploy Website / deploy (push) Has been skipped
CI / check (macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / check (windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
Some checks failed
CI / check (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Successful in 10m3s
Deploy Website / deploy (push) Has been skipped
CI / check (macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / check (windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
This commit is contained in:
1
.github/workflows/pages.yml
vendored
1
.github/workflows/pages.yml
vendored
@@ -16,6 +16,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
environment:
|
environment:
|
||||||
name: github-pages
|
name: github-pages
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
|||||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -14,6 +14,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -153,6 +154,7 @@ jobs:
|
|||||||
path: target/bundled/cagire-plugins.vst3
|
path: target/bundled/cagire-plugins.vst3
|
||||||
|
|
||||||
build-cross:
|
build-cross:
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
|
|
||||||
@@ -200,6 +202,7 @@ jobs:
|
|||||||
path: target/${{ matrix.target }}/release/cagire-desktop
|
path: target/${{ matrix.target }}/release/cagire-desktop
|
||||||
|
|
||||||
universal-macos:
|
universal-macos:
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: macos-14
|
runs-on: macos-14
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
@@ -308,7 +311,7 @@ jobs:
|
|||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [build, build-cross, universal-macos]
|
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
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ pub use scroll_indicators::{render_scroll_indicators, IndicatorAlign};
|
|||||||
pub use search_bar::render_search_bar;
|
pub use search_bar::render_search_bar;
|
||||||
pub use section_header::render_section_header;
|
pub use section_header::render_section_header;
|
||||||
pub use sparkles::Sparkles;
|
pub use sparkles::Sparkles;
|
||||||
pub use spectrum::Spectrum;
|
pub use spectrum::{Spectrum, SpectrumStyle};
|
||||||
pub use text_input::TextInputModal;
|
pub use text_input::TextInputModal;
|
||||||
pub use vu_meter::VuMeter;
|
pub use vu_meter::VuMeter;
|
||||||
pub use waveform::Waveform;
|
pub use waveform::Waveform;
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ use std::cell::RefCell;
|
|||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
static PATTERNS: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
|
static PATTERNS: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
|
||||||
|
static TRAIL: RefCell<TrailState> = const { RefCell::new(TrailState { fine_w: 0, fine_h: 0, heat: Vec::new() }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TrailState {
|
||||||
|
fine_w: usize,
|
||||||
|
fine_h: usize,
|
||||||
|
heat: Vec<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// XY oscilloscope plotting left vs right channels as a Lissajous curve.
|
/// XY oscilloscope plotting left vs right channels as a Lissajous curve.
|
||||||
@@ -17,6 +24,7 @@ pub struct Lissajous<'a> {
|
|||||||
right: &'a [f32],
|
right: &'a [f32],
|
||||||
color: Option<Color>,
|
color: Option<Color>,
|
||||||
gain: f32,
|
gain: f32,
|
||||||
|
trails: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Lissajous<'a> {
|
impl<'a> Lissajous<'a> {
|
||||||
@@ -26,9 +34,15 @@ impl<'a> Lissajous<'a> {
|
|||||||
right,
|
right,
|
||||||
color: None,
|
color: None,
|
||||||
gain: 1.0,
|
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 {
|
pub fn color(mut self, c: Color) -> Self {
|
||||||
self.color = Some(c);
|
self.color = Some(c);
|
||||||
self
|
self
|
||||||
@@ -46,6 +60,16 @@ impl Widget for Lissajous<'_> {
|
|||||||
return;
|
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 color = self.color.unwrap_or_else(|| theme::get().meter.low);
|
||||||
let width = area.width as usize;
|
let width = area.width as usize;
|
||||||
let height = area.height 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 l = (self.left[i] * self.gain).clamp(-1.0, 1.0);
|
||||||
let r = (self.right[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_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_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_x = fine_x.min(fine_width - 1);
|
||||||
@@ -74,19 +97,7 @@ impl Widget for Lissajous<'_> {
|
|||||||
let dot_x = fine_x % 2;
|
let dot_x = fine_x % 2;
|
||||||
let dot_y = fine_y % 4;
|
let dot_y = fine_y % 4;
|
||||||
|
|
||||||
let bit = match (dot_x, dot_y) {
|
patterns[char_y * width + char_x] |= braille_bit(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 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<Option<Color>> = 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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,59 @@
|
|||||||
//! 32-band frequency spectrum bar display.
|
//! 32-band frequency spectrum display with optional peak hold.
|
||||||
|
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
use ratatui::widgets::Widget;
|
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}'];
|
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<Vec<u8>> = const { RefCell::new(Vec::new()) };
|
||||||
|
}
|
||||||
|
|
||||||
/// 32-band spectrum analyzer using block characters.
|
/// 32-band spectrum analyzer using block characters.
|
||||||
pub struct Spectrum<'a> {
|
pub struct Spectrum<'a> {
|
||||||
data: &'a [f32; 32],
|
data: &'a [f32; 32],
|
||||||
gain: f32,
|
gain: f32,
|
||||||
|
style: SpectrumStyle,
|
||||||
|
peaks: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Spectrum<'a> {
|
impl<'a> Spectrum<'a> {
|
||||||
pub fn new(data: &'a [f32; 32]) -> Self {
|
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 {
|
pub fn gain(mut self, g: f32) -> Self {
|
||||||
self.gain = g;
|
self.gain = g;
|
||||||
self
|
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<'_> {
|
impl Widget for Spectrum<'_> {
|
||||||
@@ -31,6 +62,43 @@ impl Widget for Spectrum<'_> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*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 colors = theme::get();
|
||||||
let height = area.height as f32;
|
let height = area.height as f32;
|
||||||
let base = area.width as usize / 32;
|
let base = area.width as usize / 32;
|
||||||
@@ -40,36 +108,131 @@ impl Widget for Spectrum<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut x_start = area.x;
|
let mut x_start = area.x;
|
||||||
for (band, &mag) in self.data.iter().enumerate() {
|
for (band, &mag) in data.iter().enumerate() {
|
||||||
let w = base + if band < remainder { 1 } else { 0 };
|
let w = base + if band < remainder { 1 } else { 0 };
|
||||||
if w == 0 {
|
if w == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let bar_height = (mag * self.gain).min(1.0) * height;
|
let bar_height = (mag * gain).min(1.0) * height;
|
||||||
let full_cells = bar_height as usize;
|
let full_cells = bar_height as usize;
|
||||||
let frac = bar_height - full_cells as f32;
|
let frac = bar_height - full_cells as f32;
|
||||||
let frac_idx = (frac * 8.0) as usize;
|
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 {
|
for row in 0..area.height as usize {
|
||||||
let y = area.y + area.height - 1 - row as u16;
|
let y = area.y + area.height - 1 - row as u16;
|
||||||
let ratio = row as f32 / area.height as f32;
|
let ratio = row as f32 / area.height as f32;
|
||||||
let color = if ratio < 0.33 {
|
let color = band_color(ratio, &colors);
|
||||||
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 {
|
for dx in 0..w as u16 {
|
||||||
let x = x_start + dx;
|
let x = x_start + dx;
|
||||||
if row < full_cells {
|
if row < full_cells {
|
||||||
buf[(x, y)].set_char(BLOCKS[7]).set_fg(color);
|
buf[(x, y)].set_char(BLOCKS[7]).set_fg(color);
|
||||||
} else if row == full_cells && frac_idx > 0 {
|
} else if row == full_cells && frac_idx > 0 {
|
||||||
buf[(x, y)].set_char(BLOCKS[frac_idx - 1]).set_fg(color);
|
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;
|
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!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -418,6 +418,11 @@ impl App {
|
|||||||
AppCommand::ToggleScope => self.audio.config.show_scope = !self.audio.config.show_scope,
|
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::ToggleSpectrum => self.audio.config.show_spectrum = !self.audio.config.show_spectrum,
|
||||||
AppCommand::ToggleLissajous => self.audio.config.show_lissajous = !self.audio.config.show_lissajous,
|
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::TogglePreview => self.audio.config.show_preview = !self.audio.config.show_preview,
|
||||||
AppCommand::SetGainBoost(g) => self.audio.config.gain_boost = g,
|
AppCommand::SetGainBoost(g) => self.audio.config.gain_boost = g,
|
||||||
AppCommand::ToggleNormalizeViz => self.audio.config.normalize_viz = !self.audio.config.normalize_viz,
|
AppCommand::ToggleNormalizeViz => self.audio.config.normalize_viz = !self.audio.config.normalize_viz,
|
||||||
|
|||||||
@@ -278,6 +278,11 @@ pub enum AppCommand {
|
|||||||
ToggleScope,
|
ToggleScope,
|
||||||
ToggleSpectrum,
|
ToggleSpectrum,
|
||||||
ToggleLissajous,
|
ToggleLissajous,
|
||||||
|
CycleScopeMode,
|
||||||
|
FlipScopeOrientation,
|
||||||
|
ToggleLissajousTrails,
|
||||||
|
CycleSpectrumMode,
|
||||||
|
ToggleSpectrumPeaks,
|
||||||
TogglePreview,
|
TogglePreview,
|
||||||
SetGainBoost(f32),
|
SetGainBoost(f32),
|
||||||
ToggleNormalizeViz,
|
ToggleNormalizeViz,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
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;
|
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) {
|
pub fn handle_mouse(ctx: &mut InputContext, mouse: MouseEvent, term: Rect) {
|
||||||
let kind = mouse.kind;
|
let kind = mouse.kind;
|
||||||
let col = mouse.column;
|
let col = mouse.column;
|
||||||
@@ -25,7 +35,18 @@ pub fn handle_mouse(ctx: &mut InputContext, mouse: MouseEvent, term: Rect) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match kind {
|
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 => {
|
MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Moved => {
|
||||||
handle_editor_drag(ctx, col, row, term);
|
handle_editor_drag(ctx, col, row, term);
|
||||||
handle_script_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);
|
.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
|
// Sticky minimap intercepts all clicks
|
||||||
if matches!(ctx.app.ui.minimap, MinimapMode::Sticky) {
|
if matches!(ctx.app.ui.minimap, MinimapMode::Sticky) {
|
||||||
if let Some((gc, gr)) = cagire_ratatui::hit_test_tile(col, row, term) {
|
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) {
|
} else if contains(footer, col, row) {
|
||||||
handle_footer_click(ctx, col, row, footer);
|
handle_footer_click(ctx, col, row, footer);
|
||||||
} else if contains(body, col, row) {
|
} else if contains(body, col, row) {
|
||||||
handle_body_click(ctx, col, row, body);
|
handle_body_click(ctx, col, row, body, kind);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,8 +300,10 @@ fn handle_scroll(ctx: &mut InputContext, col: u16, row: u16, term: Rect, up: boo
|
|||||||
|
|
||||||
// --- Header ---
|
// --- Header ---
|
||||||
|
|
||||||
fn handle_header_click(ctx: &mut InputContext, col: u16, _row: u16, header: Rect) {
|
fn handle_header_click(ctx: &mut InputContext, col: u16, row: u16, header: Rect) {
|
||||||
let [transport_area, _live, _tempo, _bank, _pattern, _stats] = Layout::horizontal([
|
let [logo_area, transport_area, _live, tempo_area, _bank, pattern_area, stats_area] =
|
||||||
|
Layout::horizontal([
|
||||||
|
Constraint::Length(5),
|
||||||
Constraint::Min(12),
|
Constraint::Min(12),
|
||||||
Constraint::Length(9),
|
Constraint::Length(9),
|
||||||
Constraint::Min(14),
|
Constraint::Min(14),
|
||||||
@@ -290,8 +313,17 @@ fn handle_header_click(ctx: &mut InputContext, col: u16, _row: u16, header: Rect
|
|||||||
])
|
])
|
||||||
.areas(header);
|
.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);
|
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 ---
|
// --- 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
|
// Account for side panel splitting
|
||||||
let page_area = if ctx.app.panel.visible && ctx.app.panel.side.is_some() {
|
let page_area = if ctx.app.panel.visible && ctx.app.panel.side.is_some() {
|
||||||
if body.width >= 120 {
|
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 {
|
match ctx.app.page {
|
||||||
Page::Main => handle_main_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),
|
Page::Patterns => handle_patterns_click(ctx, col, row, page_area, kind),
|
||||||
Page::Help => handle_help_click(ctx, col, row, page_area),
|
Page::Help => handle_help_click(ctx, col, row, page_area),
|
||||||
Page::Dict => handle_dict_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::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),
|
Page::Script => handle_script_click(ctx, col, row, page_area),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Main page (grid) ---
|
// --- 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);
|
let [main_area, _, _vu_area] = main_view::layout(area);
|
||||||
|
|
||||||
if !contains(main_area, col, row) {
|
if !contains(main_area, col, row) {
|
||||||
return;
|
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);
|
let sequencer_area = main_view::sequencer_rect(ctx.app, main_area);
|
||||||
|
|
||||||
if !contains(sequencer_area, col, row) {
|
if !contains(sequencer_area, col, row) {
|
||||||
@@ -377,7 +415,103 @@ 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) {
|
if let Some(step) = hit_test_grid(ctx, col, row, sequencer_area) {
|
||||||
ctx.dispatch(AppCommand::GoToStep(step));
|
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<AppCommand> {
|
||||||
|
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<Constraint> = panels.iter().map(|_| Constraint::Fill(1)).collect();
|
||||||
|
let areas: Vec<Rect> = 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<usize> {
|
fn hit_test_grid(ctx: &InputContext, col: u16, row: u16, area: Rect) -> Option<usize> {
|
||||||
@@ -402,7 +536,7 @@ fn hit_test_grid(ctx: &InputContext, col: u16, row: u16, area: Rect) -> Option<u
|
|||||||
|
|
||||||
// --- Patterns page ---
|
// --- Patterns page ---
|
||||||
|
|
||||||
fn handle_patterns_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
|
fn handle_patterns_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect, kind: ClickKind) {
|
||||||
let [banks_area, patterns_area, _] = patterns_view::layout(area);
|
let [banks_area, patterns_area, _] = patterns_view::layout(area);
|
||||||
|
|
||||||
if contains(banks_area, col, row) {
|
if contains(banks_area, col, row) {
|
||||||
@@ -414,6 +548,9 @@ fn handle_patterns_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect)
|
|||||||
if let Some(pattern) = hit_test_patterns_list(ctx, col, row, patterns_area, false) {
|
if let Some(pattern) = hit_test_patterns_list(ctx, col, row, patterns_area, false) {
|
||||||
ctx.app.patterns_nav.column = PatternsColumn::Patterns;
|
ctx.app.patterns_nav.column = PatternsColumn::Patterns;
|
||||||
ctx.dispatch(AppCommand::PatternsSelectPattern(pattern));
|
ctx.dispatch(AppCommand::PatternsSelectPattern(pattern));
|
||||||
|
if kind == ClickKind::Double {
|
||||||
|
ctx.dispatch(AppCommand::PatternsEnter);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -787,8 +924,37 @@ fn handle_script_editor_mouse(
|
|||||||
ctx.app.script_editor.editor.move_cursor_to(text_row, text_col);
|
ctx.app.script_editor.editor.move_cursor_to(text_row, text_col);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_engine_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
|
fn handle_engine_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect, kind: ClickKind) {
|
||||||
let [left_col, _, _] = engine_view::layout(area);
|
let [left_col, _, right_col] = engine_view::layout(area);
|
||||||
|
|
||||||
|
// Viz panel clicks (right column)
|
||||||
|
if contains(right_col, col, row) {
|
||||||
|
let [scope_area, _, lissajous_area, _, spectrum_area] = Layout::vertical([
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.areas(right_col);
|
||||||
|
|
||||||
|
if contains(scope_area, col, row) {
|
||||||
|
if kind == ClickKind::Double {
|
||||||
|
ctx.dispatch(AppCommand::FlipScopeOrientation);
|
||||||
|
} else {
|
||||||
|
ctx.dispatch(AppCommand::CycleScopeMode);
|
||||||
|
}
|
||||||
|
} else if contains(lissajous_area, col, row) {
|
||||||
|
ctx.dispatch(AppCommand::ToggleLissajousTrails);
|
||||||
|
} else if contains(spectrum_area, col, row) {
|
||||||
|
if kind == ClickKind::Double {
|
||||||
|
ctx.dispatch(AppCommand::ToggleSpectrumPeaks);
|
||||||
|
} else {
|
||||||
|
ctx.dispatch(AppCommand::CycleSpectrumMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if !contains(left_col, col, row) {
|
if !contains(left_col, col, row) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -17,6 +17,40 @@ impl CyclicEnum for MainLayout {
|
|||||||
const VARIANTS: &'static [Self] = &[Self::Top, Self::Bottom, Self::Left, Self::Right];
|
const VARIANTS: &'static [Self] = &[Self::Top, Self::Bottom, Self::Left, Self::Right];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum ScopeMode {
|
||||||
|
#[default]
|
||||||
|
Line,
|
||||||
|
Filled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScopeMode {
|
||||||
|
pub fn toggle(self) -> 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)]
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum RefreshRate {
|
pub enum RefreshRate {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -88,6 +122,11 @@ pub struct AudioConfig {
|
|||||||
pub gain_boost: f32,
|
pub gain_boost: f32,
|
||||||
pub normalize_viz: bool,
|
pub normalize_viz: bool,
|
||||||
pub layout: MainLayout,
|
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 {
|
impl Default for AudioConfig {
|
||||||
@@ -110,6 +149,11 @@ impl Default for AudioConfig {
|
|||||||
gain_boost: 1.0,
|
gain_boost: 1.0,
|
||||||
normalize_viz: false,
|
normalize_viz: false,
|
||||||
layout: MainLayout::default(),
|
layout: MainLayout::default(),
|
||||||
|
scope_mode: ScopeMode::default(),
|
||||||
|
scope_vertical: false,
|
||||||
|
lissajous_trails: false,
|
||||||
|
spectrum_mode: SpectrumMode::default(),
|
||||||
|
spectrum_peaks: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ pub mod sample_browser;
|
|||||||
pub mod undo;
|
pub mod undo;
|
||||||
pub mod ui;
|
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 color_scheme::ColorScheme;
|
||||||
pub use editor::{
|
pub use editor::{
|
||||||
CopiedStepData, CopiedSteps, EditorContext, EditorTarget, EuclideanField, PatternField,
|
CopiedStepData, CopiedSteps, EditorContext, EditorTarget, EuclideanField, PatternField,
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ pub struct UiState {
|
|||||||
pub demo_index: usize,
|
pub demo_index: usize,
|
||||||
pub nav_indicator_until: Option<Instant>,
|
pub nav_indicator_until: Option<Instant>,
|
||||||
pub nav_fx: RefCell<Option<Effect>>,
|
pub nav_fx: RefCell<Option<Effect>>,
|
||||||
|
pub last_click: Option<(Instant, u16, u16)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiState {
|
impl Default for UiState {
|
||||||
@@ -139,6 +140,7 @@ impl Default for UiState {
|
|||||||
demo_index: 0,
|
demo_index: 0,
|
||||||
nav_indicator_until: None,
|
nav_indicator_until: None,
|
||||||
nav_fx: RefCell::new(None),
|
nav_fx: RefCell::new(None),
|
||||||
|
last_click: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ use ratatui::Frame;
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::state::{DeviceKind, EngineSection, SettingKind};
|
use crate::state::{DeviceKind, EngineSection, SettingKind};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
|
use crate::state::{ScopeMode, SpectrumMode};
|
||||||
use crate::widgets::{
|
use crate::widgets::{
|
||||||
render_scroll_indicators, render_section_header, IndicatorAlign, Lissajous, Orientation, Scope,
|
render_scroll_indicators, render_section_header, IndicatorAlign, Lissajous, Orientation, Scope,
|
||||||
Spectrum,
|
Spectrum, SpectrumStyle, Waveform,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn layout(area: Rect) -> [Rect; 3] {
|
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);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, 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 gain = viz_gain(&app.metrics.scope, &app.audio.config);
|
||||||
|
match app.audio.config.scope_mode {
|
||||||
|
ScopeMode::Line => {
|
||||||
let scope = Scope::new(&app.metrics.scope)
|
let scope = Scope::new(&app.metrics.scope)
|
||||||
.orientation(Orientation::Horizontal)
|
.orientation(orientation)
|
||||||
.color(theme.meter.low)
|
.color(theme.meter.low)
|
||||||
.gain(gain);
|
.gain(gain);
|
||||||
frame.render_widget(scope, inner);
|
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) {
|
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)
|
let lissajous = Lissajous::new(&app.metrics.scope, &app.metrics.scope_right)
|
||||||
.color(theme.meter.low)
|
.color(theme.meter.low)
|
||||||
.gain(gain);
|
.gain(gain)
|
||||||
|
.trails(app.audio.config.lissajous_trails);
|
||||||
frame.render_widget(lissajous, inner);
|
frame.render_widget(lissajous, inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,8 +246,15 @@ fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
} else {
|
} else {
|
||||||
1.0
|
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)
|
let spectrum = Spectrum::new(&app.metrics.spectrum)
|
||||||
.gain(gain);
|
.gain(gain)
|
||||||
|
.style(style)
|
||||||
|
.peaks(app.audio.config.spectrum_peaks);
|
||||||
frame.render_widget(spectrum, inner);
|
frame.render_widget(spectrum, inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ use crate::engine::SequencerSnapshot;
|
|||||||
use crate::state::MainLayout;
|
use crate::state::MainLayout;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::views::render::highlight_script_lines;
|
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] {
|
pub fn layout(area: Rect) -> [Rect; 3] {
|
||||||
Layout::horizontal([
|
Layout::horizontal([
|
||||||
@@ -499,12 +500,28 @@ pub(crate) fn render_scope(frame: &mut Frame, app: &App, area: Rect, orientation
|
|||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, 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 gain = viz_gain(&app.metrics.scope, &app.audio.config);
|
||||||
|
match app.audio.config.scope_mode {
|
||||||
|
ScopeMode::Line => {
|
||||||
let scope = Scope::new(&app.metrics.scope)
|
let scope = Scope::new(&app.metrics.scope)
|
||||||
.orientation(orientation)
|
.orientation(orientation)
|
||||||
.color(theme.meter.low)
|
.color(theme.meter.low)
|
||||||
.gain(gain);
|
.gain(gain);
|
||||||
frame.render_widget(scope, inner);
|
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) {
|
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 {
|
} else {
|
||||||
1.0
|
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)
|
let spectrum = Spectrum::new(&app.metrics.spectrum)
|
||||||
.gain(gain);
|
.gain(gain)
|
||||||
|
.style(style)
|
||||||
|
.peaks(app.audio.config.spectrum_peaks);
|
||||||
frame.render_widget(spectrum, inner);
|
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)
|
let lissajous = Lissajous::new(&app.metrics.scope, &app.metrics.scope_right)
|
||||||
.color(theme.meter.low)
|
.color(theme.meter.low)
|
||||||
.gain(gain);
|
.gain(gain)
|
||||||
|
.trails(app.audio.config.lissajous_trails);
|
||||||
frame.render_widget(lissajous, inner);
|
frame.render_widget(lissajous, inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -371,8 +371,9 @@ fn render_header(
|
|||||||
|
|
||||||
let pad = Padding::vertical(1);
|
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([
|
Layout::horizontal([
|
||||||
|
Constraint::Length(5),
|
||||||
Constraint::Min(12),
|
Constraint::Min(12),
|
||||||
Constraint::Length(9),
|
Constraint::Length(9),
|
||||||
Constraint::Min(14),
|
Constraint::Min(14),
|
||||||
@@ -382,6 +383,18 @@ fn render_header(
|
|||||||
])
|
])
|
||||||
.areas(area);
|
.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
|
// Transport block
|
||||||
let (transport_bg, transport_text) = if app.playback.playing {
|
let (transport_bg, transport_text) = if app.playback.playing {
|
||||||
(theme.status.playing_bg, " ▶ PLAYING ")
|
(theme.status.playing_bg, " ▶ PLAYING ")
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ pub use cagire_ratatui::{
|
|||||||
hint_line, render_props_form, render_scroll_indicators, render_search_bar,
|
hint_line, render_props_form, render_scroll_indicators, render_search_bar,
|
||||||
render_section_header, CategoryItem, CategoryList, ConfirmModal, FileBrowserModal,
|
render_section_header, CategoryItem, CategoryList, ConfirmModal, FileBrowserModal,
|
||||||
IndicatorAlign, Lissajous, ModalFrame, NavMinimap, NavTile, Orientation, SampleBrowser, Scope,
|
IndicatorAlign, Lissajous, ModalFrame, NavMinimap, NavTile, Orientation, SampleBrowser, Scope,
|
||||||
Selection, Spectrum, TextInputModal, VuMeter, Waveform,
|
Selection, Spectrum, SpectrumStyle, TextInputModal, VuMeter, Waveform,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -230,19 +230,19 @@ fn noall_clears_across_evaluations() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn rec() {
|
fn rec() {
|
||||||
let outputs = expect_outputs(r#""loop1" rec"#, 1);
|
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]
|
#[test]
|
||||||
fn overdub() {
|
fn overdub() {
|
||||||
let outputs = expect_outputs(r#""loop1" overdub"#, 1);
|
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]
|
#[test]
|
||||||
fn overdub_alias_dub() {
|
fn overdub_alias_dub() {
|
||||||
let outputs = expect_outputs(r#""loop1" dub"#, 1);
|
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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user