Feat: UI / UX improvements once more (mouse)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -9,6 +9,13 @@ use std::cell::RefCell;
|
||||
|
||||
thread_local! {
|
||||
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.
|
||||
@@ -17,6 +24,7 @@ pub struct Lissajous<'a> {
|
||||
right: &'a [f32],
|
||||
color: Option<Color>,
|
||||
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<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 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<Vec<u8>> = 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!(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user