Skip to main content

embedded_gui/
visual_widgets.rs

1//! Custom visual widgets & DSP-accelerated UI controls for embedded-gui.
2//!
3//! Includes:
4//! 1. `BusyWheel` — Activity indicator spinner with orbiting dot trail
5//! 2. `GaugeWidget` — Analog gauge dial helper with value needle
6//! 3. `TouchInputFilter` — DSP Biquad touch & pointer coordinate damping (`embedded-dsp` integration)
7//! 4. `SpectrumAnalyzerWidget` — Real-time DSP signal & telemetry bar visualizer (`embedded-dsp` integration)
8
9#[cfg(not(feature = "std"))]
10use crate::math::F32Ext as _;
11use crate::{
12    geometry::Rect,
13    render::{PixelRead, RenderCtx},
14};
15use embedded_graphics_core::{
16    draw_target::DrawTarget,
17    pixelcolor::{Rgb565, RgbColor, WebColors},
18};
19
20/// Activity Indicator / Busy Wheel widget.
21///
22/// Renders orbiting dots around a center point with alpha trail decay.
23#[derive(Debug, Clone, Copy)]
24pub struct BusyWheel {
25    pub center_x: i32,
26    pub center_y: i32,
27    pub radius: u32,
28    pub dot_count: u8,
29    pub dot_radius: u32,
30    pub phase: f32,
31    pub color: Rgb565,
32    pub opacity: u8,
33}
34
35impl BusyWheel {
36    pub fn new(center_x: i32, center_y: i32, radius: u32) -> Self {
37        Self {
38            center_x,
39            center_y,
40            radius,
41            dot_count: 8,
42            dot_radius: 3,
43            phase: 0.0,
44            color: Rgb565::CSS_CYAN,
45            opacity: 255,
46        }
47    }
48
49    pub fn draw<D, C>(&self, ctx: &mut RenderCtx<D, C>) -> Result<(), D::Error>
50    where
51        D: DrawTarget<Color = Rgb565> + PixelRead,
52        C: crate::render::Compositor<D>,
53    {
54        if self.opacity == 0 || self.dot_count == 0 {
55            return Ok(());
56        }
57
58        let step_angle = 2.0 * core::f32::consts::PI / self.dot_count as f32;
59        let dr = self.dot_radius as i32;
60
61        for i in 0..self.dot_count {
62            let angle = self.phase + (i as f32 * step_angle);
63            let dx = (angle.cos() * self.radius as f32) as i32;
64            let dy = (angle.sin() * self.radius as f32) as i32;
65            let px = self.center_x + dx;
66            let py = self.center_y + dy;
67
68            let dot_rect = Rect::new(px - dr, py - dr, (dr * 2 + 1) as u32, (dr * 2 + 1) as u32);
69            ctx.fill_rounded_rect(dot_rect, dr as u8, self.color)?;
70        }
71
72        Ok(())
73    }
74}
75
76/// Analog Gauge dial widget.
77///
78/// Renders a circular gauge background, tick marks, and value needle.
79#[derive(Debug, Clone, Copy)]
80pub struct GaugeWidget {
81    pub bounds: Rect,
82    pub min_val: f32,
83    pub max_val: f32,
84    pub current_val: f32,
85    pub needle_color: Rgb565,
86    pub dial_color: Rgb565,
87    pub arc_color: Rgb565,
88}
89
90impl GaugeWidget {
91    pub fn new(bounds: Rect, min_val: f32, max_val: f32) -> Self {
92        Self {
93            bounds,
94            min_val,
95            max_val,
96            current_val: min_val,
97            needle_color: Rgb565::RED,
98            dial_color: Rgb565::new(4, 8, 4),
99            arc_color: Rgb565::GREEN,
100        }
101    }
102
103    pub fn draw<D, C>(&self, ctx: &mut RenderCtx<D, C>) -> Result<(), D::Error>
104    where
105        D: DrawTarget<Color = Rgb565> + PixelRead,
106        C: crate::render::Compositor<D>,
107    {
108        // Dial card fill
109        ctx.fill_rounded_rect(self.bounds, 6, self.dial_color)?;
110
111        let center_x = self.bounds.x + (self.bounds.w as i32 / 2);
112        let center_y = self.bounds.y + (self.bounds.h as i32 / 2);
113        let radius = (self.bounds.w.min(self.bounds.h) as f32 * 0.4) as i32;
114
115        // Value fraction in [0.0, 1.0]
116        let range = (self.max_val - self.min_val).max(0.001);
117        let norm_val = ((self.current_val - self.min_val) / range).clamp(0.0, 1.0);
118
119        // Angle from -135 deg to +135 deg
120        let angle_deg = -135.0 + norm_val * 270.0;
121        let angle_rad = angle_deg * core::f32::consts::PI / 180.0;
122
123        let nx = center_x + (angle_rad.cos() * radius as f32) as i32;
124        let ny = center_y + (angle_rad.sin() * radius as f32) as i32;
125
126        // Needle line
127        ctx.draw_line(center_x, center_y, nx, ny, self.needle_color)?;
128
129        // Pivot center cap
130        let pivot_rect = Rect::new(center_x - 2, center_y - 2, 5, 5);
131        ctx.fill_rounded_rect(pivot_rect, 2, Rgb565::WHITE)?;
132
133        Ok(())
134    }
135}
136
137/// Touch & Pointer coordinate low-pass damping filter using `embedded-dsp`.
138#[cfg(feature = "embedded-dsp")]
139pub struct TouchInputFilter {
140    coeffs: [f32; 5],
141    state_x: [f32; 4],
142    state_y: [f32; 4],
143    initialized: bool,
144}
145
146#[cfg(feature = "embedded-dsp")]
147impl TouchInputFilter {
148    /// Create a new low-pass touch input filter for 2D pointer coordinates.
149    pub fn new(_cutoff_freq_ratio: f32) -> Self {
150        let coeffs = [0.0675, 0.1349, 0.0675, 1.1430, -0.4128];
151        Self {
152            coeffs,
153            state_x: [0.0; 4],
154            state_y: [0.0; 4],
155            initialized: false,
156        }
157    }
158
159    /// Process raw touch coordinates (x, y) through low-pass DSP filter to eliminate noise/jitter.
160    pub fn filter(&mut self, raw_x: f32, raw_y: f32) -> (f32, f32) {
161        if !self.initialized {
162            self.state_x.fill(raw_x);
163            self.state_y.fill(raw_y);
164            self.initialized = true;
165        }
166
167        let mut inst_x = embedded_dsp::filtering::BiquadCascadeInstanceF32 {
168            num_stages: 1,
169            coeffs: &self.coeffs,
170            state: &mut self.state_x,
171        };
172        let mut out_x = 0.0f32;
173        embedded_dsp::filtering::biquad_cascade_df1_f32(
174            &mut inst_x,
175            &[raw_x],
176            core::slice::from_mut(&mut out_x),
177        );
178
179        let mut inst_y = embedded_dsp::filtering::BiquadCascadeInstanceF32 {
180            num_stages: 1,
181            coeffs: &self.coeffs,
182            state: &mut self.state_y,
183        };
184        let mut out_y = 0.0f32;
185        embedded_dsp::filtering::biquad_cascade_df1_f32(
186            &mut inst_y,
187            &[raw_y],
188            core::slice::from_mut(&mut out_y),
189        );
190
191        (out_x, out_y)
192    }
193
194    /// Reset filter state (e.g. on pointer release or new touch down).
195    pub fn reset(&mut self) {
196        self.state_x.fill(0.0);
197        self.state_y.fill(0.0);
198        self.initialized = false;
199    }
200}
201
202/// Real-time DSP signal & telemetry bar visualizer widget.
203#[cfg(feature = "embedded-dsp")]
204pub struct SpectrumAnalyzerWidget<'a> {
205    pub bounds: Rect,
206    pub signal_samples: &'a [f32],
207    pub bar_color: Rgb565,
208    pub bg_color: Rgb565,
209}
210
211#[cfg(feature = "embedded-dsp")]
212impl<'a> SpectrumAnalyzerWidget<'a> {
213    pub fn new(bounds: Rect, signal_samples: &'a [f32]) -> Self {
214        Self {
215            bounds,
216            signal_samples,
217            bar_color: Rgb565::CSS_LIME_GREEN,
218            bg_color: Rgb565::new(2, 4, 2),
219        }
220    }
221
222    pub fn draw<D, C>(&self, ctx: &mut RenderCtx<D, C>) -> Result<(), D::Error>
223    where
224        D: DrawTarget<Color = Rgb565> + PixelRead,
225        C: crate::render::Compositor<D>,
226    {
227        ctx.fill_rounded_rect(self.bounds, 4, self.bg_color)?;
228
229        if self.signal_samples.is_empty() {
230            return Ok(());
231        }
232
233        // Calculate RMS power & peak stats via embedded-dsp
234        let mut rms = 0.0f32;
235        let _ = embedded_dsp::statistics::rms_f32(self.signal_samples, &mut rms);
236
237        let mut max_val = 0.0f32;
238        let mut idx = 0;
239        let _ = embedded_dsp::statistics::max_f32(self.signal_samples, &mut max_val, &mut idx);
240
241        let bar_count = (self.bounds.w / 6).max(1) as usize;
242        let chunk_size = (self.signal_samples.len() / bar_count).max(1);
243
244        for i in 0..bar_count {
245            let start = i * chunk_size;
246            let end = (start + chunk_size).min(self.signal_samples.len());
247            let chunk = &self.signal_samples[start..end];
248            let mut chunk_rms = 0.0f32;
249            if !chunk.is_empty() {
250                let _ = embedded_dsp::statistics::rms_f32(chunk, &mut chunk_rms);
251            }
252
253            let norm_h = (chunk_rms / (max_val.max(rms).max(0.001))).clamp(0.05, 1.0);
254            let bar_h = (self.bounds.h as f32 * norm_h) as u32;
255
256            let bx = self.bounds.x + (i as i32 * 6);
257            let by = self.bounds.bottom() - bar_h as i32;
258
259            let bar_rect = Rect::new(bx + 1, by, 4, bar_h);
260            ctx.fill_rect(bar_rect, self.bar_color)?;
261        }
262
263        Ok(())
264    }
265}