Skip to main content

openlogi_core/hid/
dpi.rs

1//! DPI read-back snapshot and capability math — pure data, no I/O.
2//!
3//! The HID++ reads/writes that produce a [`DpiInfo`] live in
4//! `openlogi_hid::write::dpi`.
5
6use serde::{Deserialize, Serialize};
7
8use super::WriteError;
9
10/// Supported DPI values reported by a device's HID++ AdjustableDpi feature.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct DpiCapabilities {
13    values: Vec<u16>,
14}
15
16impl DpiCapabilities {
17    /// Build capabilities from a device-reported DPI list. Values are sorted
18    /// and deduplicated so callers can rely on stable ordering.
19    pub fn new(mut values: Vec<u16>) -> Result<Self, WriteError> {
20        values.sort_unstable();
21        values.dedup();
22        if values.is_empty() {
23            return Err(WriteError::EmptyDpiList);
24        }
25        Ok(Self { values })
26    }
27
28    /// All supported DPI values, sorted ascending.
29    #[must_use]
30    pub fn values(&self) -> &[u16] {
31        &self.values
32    }
33
34    /// Minimum supported DPI.
35    #[must_use]
36    pub fn min(&self) -> u16 {
37        self.values[0]
38    }
39
40    /// Maximum supported DPI.
41    #[must_use]
42    pub fn max(&self) -> u16 {
43        self.values[self.values.len() - 1]
44    }
45
46    /// Whether `dpi` is exactly supported by the device.
47    #[must_use]
48    pub fn contains(&self, dpi: u16) -> bool {
49        self.values.binary_search(&dpi).is_ok()
50    }
51
52    /// The supported DPI nearest to `dpi`.
53    #[must_use]
54    pub fn nearest(&self, dpi: u32) -> u16 {
55        let mut nearest = self.values[0];
56        let mut best_delta = u32::from(nearest).abs_diff(dpi);
57        for &candidate in &self.values[1..] {
58            let delta = u32::from(candidate).abs_diff(dpi);
59            if delta < best_delta {
60                nearest = candidate;
61                best_delta = delta;
62            }
63        }
64        nearest
65    }
66
67    /// Snap `dpi` to the nearest supported value, widened to `u32` for UI math.
68    /// The single home for "round a DPI onto this device's grid" — callers that
69    /// hold an `Option<DpiCapabilities>` should `map_or(dpi, |c| c.snap(dpi))`.
70    #[must_use]
71    pub fn snap(&self, dpi: u32) -> u32 {
72        u32::from(self.nearest(dpi))
73    }
74
75    /// Best-effort step size for UI widgets that need a single increment.
76    /// Returns the smallest positive gap between adjacent reported values.
77    #[must_use]
78    pub fn step_hint(&self) -> u16 {
79        self.values
80            .array_windows::<2>()
81            .filter_map(|&[low, high]| high.checked_sub(low))
82            .filter(|step| *step > 0)
83            .min()
84            .unwrap_or(1)
85    }
86
87    /// A supported value different from `current`, for diagnostic write tests.
88    #[must_use]
89    pub fn adjacent_test_target(&self, current: u16) -> Option<u16> {
90        if self.values.len() < 2 {
91            return None;
92        }
93        match self.values.binary_search(&current) {
94            Ok(index) if index + 1 < self.values.len() => Some(self.values[index + 1]),
95            Ok(index) if index > 0 => Some(self.values[index - 1]),
96            Ok(_) => None,
97            Err(index) if index < self.values.len() => Some(self.values[index]),
98            Err(_) => self.values.last().copied(),
99        }
100        .filter(|target| *target != current)
101    }
102}
103
104/// Current DPI plus the supported values reported by the device.
105///
106/// Crosses the agent↔GUI IPC (`read_dpi`, [`DpiCapabilities`] included), so
107/// field order is wire format — changes require a `PROTOCOL_VERSION` bump
108/// (guarded by `openlogi-ipc/tests/wire_format.rs`).
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct DpiInfo {
111    /// DPI currently configured on sensor 0.
112    pub current: u16,
113    /// Supported values reported by the device for sensor 0.
114    pub capabilities: DpiCapabilities,
115}
116
117#[cfg(test)]
118mod tests {
119    use std::assert_matches;
120
121    use super::{DpiCapabilities, WriteError};
122
123    #[test]
124    fn capabilities_sort_and_deduplicate_values() -> Result<(), WriteError> {
125        let caps = DpiCapabilities::new(vec![1600, 400, 800, 800])?;
126
127        assert_eq!(caps.values(), [400, 800, 1600]);
128        assert_eq!(caps.min(), 400);
129        assert_eq!(caps.max(), 1600);
130        Ok(())
131    }
132
133    #[test]
134    fn capabilities_reject_empty_list() {
135        assert_matches!(
136            DpiCapabilities::new(Vec::new()),
137            Err(WriteError::EmptyDpiList)
138        );
139    }
140
141    #[test]
142    fn nearest_returns_closest_supported_value() -> Result<(), WriteError> {
143        let caps = DpiCapabilities::new(vec![400, 800, 1600])?;
144
145        assert_eq!(caps.nearest(390), 400);
146        assert_eq!(caps.nearest(1000), 800);
147        assert_eq!(caps.nearest(2000), 1600);
148        Ok(())
149    }
150
151    #[test]
152    fn step_hint_returns_smallest_positive_gap() -> Result<(), WriteError> {
153        let caps = DpiCapabilities::new(vec![400, 800, 1200, 2000])?;
154
155        assert_eq!(caps.step_hint(), 400);
156        Ok(())
157    }
158
159    #[test]
160    fn adjacent_test_target_prefers_next_then_previous_value() -> Result<(), WriteError> {
161        let caps = DpiCapabilities::new(vec![400, 800, 1600])?;
162
163        assert_eq!(caps.adjacent_test_target(400), Some(800));
164        assert_eq!(caps.adjacent_test_target(800), Some(1600));
165        assert_eq!(caps.adjacent_test_target(1600), Some(800));
166        Ok(())
167    }
168
169    #[test]
170    fn adjacent_test_target_handles_current_outside_list() -> Result<(), WriteError> {
171        let caps = DpiCapabilities::new(vec![400, 800, 1600])?;
172
173        assert_eq!(caps.adjacent_test_target(1000), Some(1600));
174        assert_eq!(caps.adjacent_test_target(2000), Some(1600));
175        Ok(())
176    }
177}