Skip to main content

aethermap_gui/widgets/
analog_visualizer.rs

1//! Analog stick position visualizer widget
2//!
3//! Provides real-time visualization of analog stick position with
4//! deadzone overlay and range indicators.
5
6use iced::widget::canvas::{self, event, Cache, Frame, Geometry, Path, Program, Stroke};
7use iced::{Color, Point, Rectangle};
8use iced::mouse;
9use std::sync::Arc;
10
11/// Deadzone shape (matches gui.rs enum)
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum DeadzoneShape {
14    Circular,
15    Square,
16}
17
18/// Canvas-based analog stick visualizer
19///
20/// Displays the current stick position as a dot, with deadzone
21/// shown as a shaded region (circle or square). The outer circle
22/// represents the full range of motion.
23///
24/// Uses canvas::Cache to optimize rendering: static elements
25/// (outer circle, deadzone, axes) are cached and only redrawn
26/// when deadzone or shape changes. Only the stick position
27/// dot is redrawn every frame.
28///
29/// The cache is wrapped in Arc to allow sharing across widget instances
30/// since Cache doesn't implement Clone.
31pub struct AnalogVisualizer {
32    /// Current stick position X (-1.0 to 1.0)
33    pub stick_x: f32,
34    /// Current stick position Y (-1.0 to 1.0)
35    pub stick_y: f32,
36    /// Deadzone radius (0.0 to 1.0)
37    pub deadzone: f32,
38    /// Deadzone shape
39    pub deadzone_shape: DeadzoneShape,
40    /// Range minimum value (typically -32768)
41    pub range_min: i32,
42    /// Range maximum value (typically 32767)
43    pub range_max: i32,
44    /// Cache for static elements (deadzone, axes, outer bounds)
45    /// Wrapped in Arc for sharing across widget instances
46    pub cache: Arc<Cache>,
47}
48
49impl Default for AnalogVisualizer {
50    fn default() -> Self {
51        Self {
52            stick_x: 0.0,
53            stick_y: 0.0,
54            deadzone: 0.15,
55            deadzone_shape: DeadzoneShape::Circular,
56            range_min: -32768,
57            range_max: 32767,
58            cache: Arc::new(Cache::default()),
59        }
60    }
61}
62
63impl<Message> Program<Message> for AnalogVisualizer {
64    type State = ();
65
66    fn update(
67        &self,
68        _state: &mut Self::State,
69        _event: canvas::Event,
70        _bounds: Rectangle,
71        _cursor: mouse::Cursor,
72    ) -> (event::Status, Option<Message>) {
73        (event::Status::Ignored, None)
74    }
75
76    fn draw(
77        &self,
78        _state: &Self::State,
79        renderer: &iced::Renderer,
80        _theme: &iced::Theme,
81        bounds: Rectangle,
82        _cursor: mouse::Cursor,
83    ) -> Vec<Geometry> {
84        let center = Point::new(bounds.width / 2.0, bounds.height / 2.0);
85        let size = bounds.width.min(bounds.height);
86        let outer_radius = size * 0.45;
87
88        // Draw static background with cache (outer circle, deadzone, axes)
89        let background = self.cache.draw(renderer, bounds.size(), |frame| {
90            // Draw outer bounds (circle representing full range)
91            let outer_circle = Path::circle(center, outer_radius);
92            frame.fill(&outer_circle, Color::from_rgb(0.15, 0.15, 0.15));
93            frame.stroke(
94                &outer_circle,
95                Stroke::default()
96                    .with_color(Color::from_rgb(0.4, 0.4, 0.4))
97                    .with_width(2.0),
98            );
99
100            // Draw deadzone (filled circle or square)
101            let deadzone_radius = (outer_radius * self.deadzone.clamp(0.0, 1.0)).max(0.0);
102            let deadzone_color = Color::from_rgba(0.2, 0.5, 0.2, 0.4);
103
104            if self.deadzone_shape == DeadzoneShape::Circular && deadzone_radius > 0.5 {
105                let deadzone_circle = Path::circle(center, deadzone_radius);
106                frame.fill(&deadzone_circle, deadzone_color);
107                frame.stroke(
108                    &deadzone_circle,
109                    Stroke::default()
110                        .with_color(Color::from_rgb(0.3, 0.7, 0.3))
111                        .with_width(1.0),
112                );
113            } else if deadzone_radius > 0.5 {
114                // Square deadzone
115                let dz_size = deadzone_radius * 2.0;
116                let deadzone_rect = Path::rectangle(
117                    Point::new(center.x - deadzone_radius, center.y - deadzone_radius),
118                    iced::Size::new(dz_size, dz_size),
119                );
120                frame.fill(&deadzone_rect, deadzone_color);
121                frame.stroke(
122                    &deadzone_rect,
123                    Stroke::default()
124                        .with_color(Color::from_rgb(0.3, 0.7, 0.3))
125                        .with_width(1.0),
126                );
127            }
128
129            // Draw crosshairs (axes)
130            let h_line = Path::line(
131                Point::new(center.x - outer_radius, center.y),
132                Point::new(center.x + outer_radius, center.y),
133            );
134            let v_line = Path::line(
135                Point::new(center.x, center.y - outer_radius),
136                Point::new(center.x, center.y + outer_radius),
137            );
138            frame.stroke(
139                &h_line,
140                Stroke::default()
141                    .with_color(Color::from_rgba(0.5, 0.5, 0.5, 0.3))
142                    .with_width(1.0),
143            );
144            frame.stroke(
145                &v_line,
146                Stroke::default()
147                    .with_color(Color::from_rgba(0.5, 0.5, 0.5, 0.3))
148                    .with_width(1.0),
149            );
150
151            // Draw center point
152            let center_dot = Path::circle(center, 3.0);
153            frame.fill(&center_dot, Color::from_rgb(0.6, 0.6, 0.6));
154        });
155
156        // Draw dynamic stick position fresh each frame
157        let mut frame = Frame::new(renderer, bounds.size());
158
159        // Clamp stick position to valid range
160        let stick_x_clamped = self.stick_x.clamp(-1.0, 1.0);
161        let stick_y_clamped = self.stick_y.clamp(-1.0, 1.0);
162
163        let stick_offset_x = stick_x_clamped * outer_radius;
164        // Invert Y for screen coordinates (analog Y+ = up, screen Y+ = down)
165        let stick_offset_y = -stick_y_clamped * outer_radius;
166        let stick_pos = Point::new(center.x + stick_offset_x, center.y + stick_offset_y);
167
168        let stick_dot = Path::circle(stick_pos, 6.0);
169        frame.fill(&stick_dot, Color::from_rgb(0.9, 0.3, 0.3));
170        frame.stroke(
171            &stick_dot,
172            Stroke::default()
173                .with_color(Color::from_rgb(1.0, 1.0, 1.0))
174                .with_width(1.0),
175        );
176
177        vec![background, frame.into_geometry()]
178    }
179}
180
181impl AnalogVisualizer {
182    /// Clear the cached geometry.
183    ///
184    /// Call this when deadzone or deadzone shape changes to force
185    /// a redraw of the static background elements.
186    pub fn clear_cache(&self) {
187        self.cache.clear();
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_analog_visualizer_default() {
197        let viz = AnalogVisualizer::default();
198        assert_eq!(viz.stick_x, 0.0);
199        assert_eq!(viz.stick_y, 0.0);
200        assert_eq!(viz.deadzone, 0.15);
201        assert_eq!(viz.deadzone_shape, DeadzoneShape::Circular);
202    }
203
204    #[test]
205    fn test_analog_visualizer_with_values() {
206        let viz = AnalogVisualizer {
207            stick_x: 0.5,
208            stick_y: -0.3,
209            deadzone: 0.2,
210            deadzone_shape: DeadzoneShape::Square,
211            range_min: -32768,
212            range_max: 32767,
213            cache: Arc::new(Cache::default()),
214        };
215        assert_eq!(viz.stick_x, 0.5);
216        assert_eq!(viz.stick_y, -0.3);
217        assert_eq!(viz.deadzone, 0.2);
218        assert_eq!(viz.deadzone_shape, DeadzoneShape::Square);
219    }
220
221    #[test]
222    fn test_deadzone_shapes() {
223        let circular = AnalogVisualizer {
224            deadzone_shape: DeadzoneShape::Circular,
225            ..Default::default()
226        };
227        assert_eq!(circular.deadzone_shape, DeadzoneShape::Circular);
228
229        let square = AnalogVisualizer {
230            deadzone_shape: DeadzoneShape::Square,
231            ..Default::default()
232        };
233        assert_eq!(square.deadzone_shape, DeadzoneShape::Square);
234    }
235
236    #[test]
237    fn test_range_values() {
238        let viz = AnalogVisualizer {
239            range_min: -16384,
240            range_max: 16383,
241            ..Default::default()
242        };
243        assert_eq!(viz.range_min, -16384);
244        assert_eq!(viz.range_max, 16383);
245    }
246
247    #[test]
248    fn test_stick_position_clamping_bounds() {
249        // Test that stick values can be set to valid bounds
250        let viz = AnalogVisualizer {
251            stick_x: 1.0,
252            stick_y: 1.0,
253            ..Default::default()
254        };
255        assert_eq!(viz.stick_x, 1.0);
256        assert_eq!(viz.stick_y, 1.0);
257
258        let viz_negative = AnalogVisualizer {
259            stick_x: -1.0,
260            stick_y: -1.0,
261            ..Default::default()
262        };
263        assert_eq!(viz_negative.stick_x, -1.0);
264        assert_eq!(viz_negative.stick_y, -1.0);
265    }
266
267    #[test]
268    fn test_clear_cache_exists() {
269        let viz = AnalogVisualizer::default();
270        // Just verify the method exists and doesn't panic
271        viz.clear_cache();
272        // Cache is wrapped in Arc, so we can't directly inspect its state
273        // But we've verified the method is callable
274    }
275}