//! 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> = 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. pub struct Lissajous<'a> { left: &'a [f32], right: &'a [f32], color: Option, 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> = 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 } }