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