Skip to main content

aethermap_gui/views/
analog.rs

1use crate::gui::Message;
2use crate::theme;
3use crate::widgets::{
4    analog_visualizer::DeadzoneShape as WidgetDeadzoneShape, AnalogVisualizer, CurveGraph,
5};
6use aethermap_common::{AnalogMode, CameraOutputMode};
7use iced::{
8    widget::{button, container, pick_list, scrollable, slider, text},
9    Color, Element, Length,
10};
11use std::sync::Arc;
12use std::time::Instant;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum DeadzoneShape {
16    #[default]
17    Circular,
18    Square,
19}
20
21impl std::fmt::Display for DeadzoneShape {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match self {
24            DeadzoneShape::Circular => write!(f, "Circular"),
25            DeadzoneShape::Square => write!(f, "Square"),
26        }
27    }
28}
29
30impl DeadzoneShape {
31    pub const ALL: [DeadzoneShape; 2] = [DeadzoneShape::Circular, DeadzoneShape::Square];
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum SensitivityCurve {
36    #[default]
37    Linear,
38    Quadratic,
39    Exponential,
40}
41
42impl std::fmt::Display for SensitivityCurve {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            SensitivityCurve::Linear => write!(f, "Linear"),
46            SensitivityCurve::Quadratic => write!(f, "Quadratic"),
47            SensitivityCurve::Exponential => write!(f, "Exponential"),
48        }
49    }
50}
51
52impl SensitivityCurve {
53    pub const ALL: [SensitivityCurve; 3] = [
54        SensitivityCurve::Linear,
55        SensitivityCurve::Quadratic,
56        SensitivityCurve::Exponential,
57    ];
58}
59
60#[derive(Debug, Clone)]
61pub struct CalibrationConfig {
62    pub deadzone: f32,
63    pub deadzone_shape: String,
64    pub sensitivity: String,
65    pub sensitivity_multiplier: f32,
66    pub range_min: i32,
67    pub range_max: i32,
68    pub invert_x: bool,
69    pub invert_y: bool,
70    pub exponent: f32,
71}
72
73impl Default for CalibrationConfig {
74    fn default() -> Self {
75        Self {
76            deadzone: 0.15,
77            deadzone_shape: "circular".to_string(),
78            sensitivity: "linear".to_string(),
79            sensitivity_multiplier: 1.0,
80            range_min: -32768,
81            range_max: 32767,
82            invert_x: false,
83            invert_y: false,
84            exponent: 2.0,
85        }
86    }
87}
88
89#[derive(Debug)]
90pub struct AnalogCalibrationView {
91    pub device_id: String,
92    pub layer_id: usize,
93    pub calibration: CalibrationConfig,
94    pub deadzone_shape_selected: DeadzoneShape,
95    pub sensitivity_curve_selected: SensitivityCurve,
96    pub analog_mode_selected: AnalogMode,
97    pub camera_mode_selected: CameraOutputMode,
98    pub invert_x_checked: bool,
99    pub invert_y_checked: bool,
100    pub stick_x: f32,
101    pub stick_y: f32,
102    pub loading: bool,
103    pub error: Option<String>,
104    pub last_visualizer_update: Instant,
105    pub visualizer_cache: Arc<iced::widget::canvas::Cache>,
106}
107
108impl Clone for AnalogCalibrationView {
109    fn clone(&self) -> Self {
110        Self {
111            device_id: self.device_id.clone(),
112            layer_id: self.layer_id,
113            calibration: self.calibration.clone(),
114            deadzone_shape_selected: self.deadzone_shape_selected,
115            sensitivity_curve_selected: self.sensitivity_curve_selected,
116            analog_mode_selected: self.analog_mode_selected,
117            camera_mode_selected: self.camera_mode_selected,
118            invert_x_checked: self.invert_x_checked,
119            invert_y_checked: self.invert_y_checked,
120            stick_x: self.stick_x,
121            stick_y: self.stick_y,
122            loading: self.loading,
123            error: self.error.clone(),
124            last_visualizer_update: Instant::now(),
125            visualizer_cache: Arc::clone(&self.visualizer_cache),
126        }
127    }
128}
129
130impl Default for AnalogCalibrationView {
131    fn default() -> Self {
132        Self {
133            device_id: String::new(),
134            layer_id: 0,
135            calibration: CalibrationConfig::default(),
136            deadzone_shape_selected: DeadzoneShape::Circular,
137            sensitivity_curve_selected: SensitivityCurve::Linear,
138            analog_mode_selected: AnalogMode::Disabled,
139            camera_mode_selected: CameraOutputMode::Scroll,
140            invert_x_checked: false,
141            invert_y_checked: false,
142            stick_x: 0.0,
143            stick_y: 0.0,
144            loading: false,
145            error: None,
146            last_visualizer_update: Instant::now(),
147            visualizer_cache: Arc::new(iced::widget::canvas::Cache::default()),
148        }
149    }
150}
151
152impl AnalogCalibrationView {
153    fn checkbox_button<'a>(
154        &'a self,
155        label: &str,
156        is_checked: bool,
157        msg: fn(bool) -> Message,
158    ) -> Element<'a, Message> {
159        let btn = if is_checked {
160            button(text(format!("[X] {}", label)).size(14))
161        } else {
162            button(text(format!("[ ] {}", label)).size(14))
163        };
164        btn.on_press(msg(is_checked))
165            .style(iced::theme::Button::Text)
166            .into()
167    }
168
169    pub fn view(&self) -> Element<'_, Message> {
170        use iced::widget::{container, horizontal_rule as rule, Canvas, Column, Row};
171
172        let title = text("Analog Calibration").size(24);
173        let info = Column::new()
174            .spacing(5)
175            .push(text(format!("Device: {}", self.device_id)).size(14))
176            .push(text(format!("Layer: {}", self.layer_id)).size(14));
177
178        let visualizer_section = Column::new()
179            .spacing(10)
180            .push(text("Stick Position").size(18))
181            .push(
182                container(
183                    Canvas::new(AnalogVisualizer {
184                        stick_x: self.stick_x,
185                        stick_y: self.stick_y,
186                        deadzone: self.calibration.deadzone,
187                        deadzone_shape: match self.deadzone_shape_selected {
188                            DeadzoneShape::Circular => WidgetDeadzoneShape::Circular,
189                            DeadzoneShape::Square => WidgetDeadzoneShape::Square,
190                        },
191                        range_min: self.calibration.range_min,
192                        range_max: self.calibration.range_max,
193                        cache: Arc::clone(&self.visualizer_cache),
194                    })
195                    .width(Length::Fixed(250.0))
196                    .height(Length::Fixed(250.0)),
197                )
198                .width(Length::Fixed(270.0))
199                .height(Length::Fixed(270.0))
200                .center_x()
201                .center_y(),
202            );
203
204        let mode_section = Column::new()
205            .spacing(10)
206            .push(text("Output Mode").size(18))
207            .push(Row::new().spacing(10).push(text("Mode:")).push(pick_list(
208                &AnalogMode::ALL[..],
209                Some(self.analog_mode_selected),
210                Message::AnalogModeChanged,
211            )));
212
213        let mode_section = if self.analog_mode_selected == AnalogMode::Camera {
214            mode_section.push(Row::new().spacing(10).push(text("Camera:")).push(pick_list(
215                &CameraOutputMode::ALL[..],
216                Some(self.camera_mode_selected),
217                Message::CameraModeChanged,
218            )))
219        } else {
220            mode_section
221        };
222
223        let deadzone_section = Column::new()
224            .spacing(10)
225            .push(text("Deadzone").size(18))
226            .push(
227                Row::new()
228                    .spacing(10)
229                    .push(text("Size:"))
230                    .push(text(format!("{:.0}%", self.calibration.deadzone * 100.0)))
231                    .push(
232                        slider(
233                            0.0..=1.0,
234                            self.calibration.deadzone,
235                            Message::AnalogDeadzoneChanged,
236                        )
237                        .step(0.01),
238                    ),
239            )
240            .push(Row::new().spacing(10).push(text("Shape:")).push(pick_list(
241                &DeadzoneShape::ALL[..],
242                Some(self.deadzone_shape_selected),
243                Message::AnalogDeadzoneShapeChanged,
244            )));
245
246        let sensitivity_section = Column::new()
247            .spacing(10)
248            .push(text("Sensitivity").size(18))
249            .push(
250                Row::new()
251                    .spacing(10)
252                    .push(text("Multiplier:"))
253                    .push(text(format!(
254                        "{:.1}",
255                        self.calibration.sensitivity_multiplier
256                    )))
257                    .push(
258                        slider(
259                            0.1..=5.0,
260                            self.calibration.sensitivity_multiplier,
261                            Message::AnalogSensitivityChanged,
262                        )
263                        .step(0.1),
264                    ),
265            )
266            .push(Row::new().spacing(10).push(text("Curve:")).push(pick_list(
267                &SensitivityCurve::ALL[..],
268                Some(self.sensitivity_curve_selected),
269                Message::AnalogSensitivityCurveChanged,
270            )))
271            .push(text(format!("Curve: {}", self.sensitivity_curve_selected)).size(14))
272            .push(
273                container(
274                    Canvas::new(CurveGraph {
275                        curve: self.sensitivity_curve_selected,
276                        multiplier: self.calibration.sensitivity_multiplier,
277                    })
278                    .width(Length::Fixed(300.0))
279                    .height(Length::Fixed(200.0)),
280                )
281                .width(Length::Fixed(320.0))
282                .center_x(),
283            );
284
285        let range_section = Column::new()
286            .spacing(10)
287            .push(text("Output Range").size(18))
288            .push(
289                Row::new()
290                    .spacing(10)
291                    .push(text("Min:"))
292                    .push(text(self.calibration.range_min.to_string()))
293                    .push(slider(
294                        -32768..=0,
295                        self.calibration.range_min,
296                        Message::AnalogRangeMinChanged,
297                    )),
298            )
299            .push(
300                Row::new()
301                    .spacing(10)
302                    .push(text("Max:"))
303                    .push(text(self.calibration.range_max.to_string()))
304                    .push(slider(
305                        0..=32767,
306                        self.calibration.range_max,
307                        Message::AnalogRangeMaxChanged,
308                    )),
309            );
310
311        let inversion_section = Column::new()
312            .spacing(10)
313            .push(text("Axis Inversion").size(18))
314            .push(
315                Row::new()
316                    .spacing(20)
317                    .push(self.checkbox_button(
318                        "Invert X",
319                        self.invert_x_checked,
320                        Message::AnalogInvertXToggled,
321                    ))
322                    .push(self.checkbox_button(
323                        "Invert Y",
324                        self.invert_y_checked,
325                        Message::AnalogInvertYToggled,
326                    )),
327            );
328
329        let buttons = Row::new()
330            .spacing(10)
331            .push(button("Apply").on_press(Message::ApplyAnalogCalibration))
332            .push(
333                button("Close")
334                    .on_press(Message::CloseAnalogCalibration)
335                    .style(iced::theme::Button::Secondary),
336            );
337
338        let content = if let Some(error) = &self.error {
339            Column::new()
340                .spacing(20)
341                .push(title)
342                .push(info)
343                .push(rule(1))
344                .push(text(format!("Error: {}", error)).style(Color::from_rgb(1.0, 0.4, 0.4)))
345                .push(buttons)
346        } else {
347            Column::new()
348                .spacing(20)
349                .push(title)
350                .push(info)
351                .push(rule(1))
352                .push(visualizer_section)
353                .push(rule(1))
354                .push(mode_section)
355                .push(rule(1))
356                .push(deadzone_section)
357                .push(rule(1))
358                .push(sensitivity_section)
359                .push(rule(1))
360                .push(range_section)
361                .push(rule(1))
362                .push(inversion_section)
363                .push(rule(1))
364                .push(buttons)
365        };
366
367        scrollable(content).height(Length::Fill).into()
368    }
369}
370
371pub fn overlay_view(state: &crate::gui::State) -> Option<Element<'_, Message>> {
372    if let Some(ref view) = state.analog_calibration_view {
373        let dialog = container(view.view())
374            .max_width(600)
375            .max_height(800)
376            .style(theme::styles::card);
377
378        Some(
379            container(dialog)
380                .width(Length::Fill)
381                .height(Length::Fill)
382                .align_x(iced::alignment::Horizontal::Center)
383                .align_y(iced::alignment::Vertical::Center)
384                .padding(40)
385                .style(iced::theme::Container::Transparent)
386                .into(),
387        )
388    } else {
389        None
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use std::time::Duration;
397
398    #[test]
399    fn test_analog_calibration_view_default() {
400        let view = AnalogCalibrationView::default();
401
402        assert_eq!(view.device_id, "");
403        assert_eq!(view.layer_id, 0);
404        assert_eq!(view.calibration.deadzone, 0.15);
405        assert_eq!(view.stick_x, 0.0);
406        assert_eq!(view.stick_y, 0.0);
407        assert!(!view.loading);
408        assert!(view.error.is_none());
409    }
410
411    #[test]
412    fn test_analog_calibration_view_with_values() {
413        let view = AnalogCalibrationView {
414            device_id: "test_device".to_string(),
415            layer_id: 1,
416            calibration: CalibrationConfig {
417                deadzone: 0.2,
418                deadzone_shape: "circular".to_string(),
419                sensitivity: "quadratic".to_string(),
420                sensitivity_multiplier: 1.5,
421                range_min: -16384,
422                range_max: 16383,
423                invert_x: true,
424                invert_y: false,
425                exponent: 2.0,
426            },
427            deadzone_shape_selected: DeadzoneShape::Square,
428            sensitivity_curve_selected: SensitivityCurve::Quadratic,
429            analog_mode_selected: AnalogMode::Mouse,
430            camera_mode_selected: CameraOutputMode::Keys,
431            invert_x_checked: true,
432            invert_y_checked: false,
433            stick_x: 0.5,
434            stick_y: -0.3,
435            loading: false,
436            error: None,
437            last_visualizer_update: Instant::now(),
438            visualizer_cache: Arc::new(iced::widget::canvas::Cache::default()),
439        };
440
441        assert_eq!(view.device_id, "test_device");
442        assert_eq!(view.layer_id, 1);
443        assert_eq!(view.calibration.deadzone, 0.2);
444        assert_eq!(view.stick_x, 0.5);
445        assert_eq!(view.stick_y, -0.3);
446        assert_eq!(view.analog_mode_selected, AnalogMode::Mouse);
447        assert_eq!(view.camera_mode_selected, CameraOutputMode::Keys);
448        assert!(view.invert_x_checked);
449        assert!(!view.invert_y_checked);
450    }
451
452    #[test]
453    fn test_calibration_config_default() {
454        let config = CalibrationConfig::default();
455
456        assert_eq!(config.deadzone, 0.15);
457        assert_eq!(config.deadzone_shape, "circular");
458        assert_eq!(config.sensitivity, "linear");
459        assert_eq!(config.sensitivity_multiplier, 1.0);
460        assert_eq!(config.range_min, -32768);
461        assert_eq!(config.range_max, 32767);
462        assert!(!config.invert_x);
463        assert!(!config.invert_y);
464        assert_eq!(config.exponent, 2.0);
465    }
466
467    #[test]
468    fn test_deadzone_shape_display() {
469        assert_eq!(DeadzoneShape::Circular.to_string(), "Circular");
470        assert_eq!(DeadzoneShape::Square.to_string(), "Square");
471    }
472
473    #[test]
474    fn test_sensitivity_curve_display() {
475        assert_eq!(SensitivityCurve::Linear.to_string(), "Linear");
476        assert_eq!(SensitivityCurve::Quadratic.to_string(), "Quadratic");
477        assert_eq!(SensitivityCurve::Exponential.to_string(), "Exponential");
478    }
479
480    #[test]
481    fn test_deadzone_shape_default() {
482        assert_eq!(DeadzoneShape::default(), DeadzoneShape::Circular);
483    }
484
485    #[test]
486    fn test_sensitivity_curve_default() {
487        assert_eq!(SensitivityCurve::default(), SensitivityCurve::Linear);
488    }
489
490    #[test]
491    fn test_analog_calibration_view_clone() {
492        let view = AnalogCalibrationView {
493            device_id: "test_device".to_string(),
494            layer_id: 1,
495            calibration: CalibrationConfig {
496                deadzone: 0.2,
497                ..Default::default()
498            },
499            ..Default::default()
500        };
501
502        let cloned = view.clone();
503        assert_eq!(cloned.device_id, "test_device");
504        assert_eq!(cloned.layer_id, 1);
505        assert_eq!(cloned.calibration.deadzone, 0.2);
506        assert!(cloned.last_visualizer_update.elapsed() < Duration::from_secs(1));
507    }
508
509    #[test]
510    fn test_throttling_threshold() {
511        let view = AnalogCalibrationView {
512            device_id: "test".to_string(),
513            layer_id: 0,
514            calibration: CalibrationConfig::default(),
515            deadzone_shape_selected: DeadzoneShape::Circular,
516            sensitivity_curve_selected: SensitivityCurve::Linear,
517            analog_mode_selected: AnalogMode::Disabled,
518            camera_mode_selected: CameraOutputMode::Scroll,
519            invert_x_checked: false,
520            invert_y_checked: false,
521            stick_x: 0.0,
522            stick_y: 0.0,
523            loading: false,
524            error: None,
525            last_visualizer_update: Instant::now(),
526            visualizer_cache: Arc::new(iced::widget::canvas::Cache::default()),
527        };
528
529        assert!(view.last_visualizer_update.elapsed() < Duration::from_millis(33));
530        std::thread::sleep(Duration::from_millis(40));
531        assert!(view.last_visualizer_update.elapsed() >= Duration::from_millis(33));
532    }
533
534    #[test]
535    fn test_visualizer_cache_arc_sharing() {
536        let cache = Arc::new(iced::widget::canvas::Cache::default());
537        let cache_clone = Arc::clone(&cache);
538        assert!(Arc::ptr_eq(&cache, &cache_clone));
539    }
540
541    #[test]
542    fn test_analog_mode_selection_states() {
543        let modes = [
544            AnalogMode::Disabled,
545            AnalogMode::Dpad,
546            AnalogMode::Gamepad,
547            AnalogMode::Camera,
548            AnalogMode::Mouse,
549            AnalogMode::Wasd,
550        ];
551
552        for mode in modes {
553            let view = AnalogCalibrationView {
554                analog_mode_selected: mode,
555                ..Default::default()
556            };
557            assert_eq!(view.analog_mode_selected, mode);
558        }
559    }
560
561    #[test]
562    fn test_camera_mode_selection_states() {
563        let modes = [CameraOutputMode::Scroll, CameraOutputMode::Keys];
564
565        for mode in modes {
566            let view = AnalogCalibrationView {
567                camera_mode_selected: mode,
568                ..Default::default()
569            };
570            assert_eq!(view.camera_mode_selected, mode);
571        }
572    }
573}