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
235 lines
7.3 KiB
Rust
235 lines
7.3 KiB
Rust
//! Lissajous XY oscilloscope widget using braille characters.
|
|
|
|
use crate::theme;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::Color;
|
|
use ratatui::widgets::Widget;
|
|
use std::cell::RefCell;
|
|
|
|
thread_local! {
|
|
static PATTERNS: RefCell<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.
|
|
pub struct Lissajous<'a> {
|
|
left: &'a [f32],
|
|
right: &'a [f32],
|
|
color: Option<Color>,
|
|
gain: f32,
|
|
trails: bool,
|
|
}
|
|
|
|
impl<'a> Lissajous<'a> {
|
|
pub fn new(left: &'a [f32], right: &'a [f32]) -> Self {
|
|
Self {
|
|
left,
|
|
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
|
|
}
|
|
|
|
pub fn gain(mut self, g: f32) -> Self {
|
|
self.gain = g;
|
|
self
|
|
}
|
|
}
|
|
|
|
impl Widget for Lissajous<'_> {
|
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
|
if area.width == 0 || area.height == 0 || self.left.is_empty() || self.right.is_empty() {
|
|
return;
|
|
}
|
|
|
|
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;
|
|
let fine_width = width * 2;
|
|
let fine_height = height * 4;
|
|
let len = self.left.len().min(self.right.len());
|
|
|
|
PATTERNS.with(|p| {
|
|
let mut patterns = p.borrow_mut();
|
|
let size = width * height;
|
|
patterns.clear();
|
|
patterns.resize(size, 0);
|
|
|
|
for i in 0..len {
|
|
let l = (self.left[i] * self.gain).clamp(-1.0, 1.0);
|
|
let r = (self.right[i] * self.gain).clamp(-1.0, 1.0);
|
|
|
|
let fine_x = ((r + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize;
|
|
let fine_y = ((1.0 - l) * 0.5 * (fine_height - 1) as f32).round() as usize;
|
|
let fine_x = fine_x.min(fine_width - 1);
|
|
let fine_y = fine_y.min(fine_height - 1);
|
|
|
|
let char_x = fine_x / 2;
|
|
let char_y = fine_y / 4;
|
|
let dot_x = fine_x % 2;
|
|
let dot_y = fine_y % 4;
|
|
|
|
patterns[char_y * width + char_x] |= braille_bit(dot_x, dot_y);
|
|
}
|
|
|
|
for cy in 0..height {
|
|
for cx in 0..width {
|
|
let pattern = patterns[cy * width + cx];
|
|
if pattern != 0 {
|
|
let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' ');
|
|
buf[(area.x + cx as u16, area.y + cy as u16)]
|
|
.set_char(ch)
|
|
.set_fg(color);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
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 }
|
|
}
|