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 std::num::TryFromIntError;
7
8use az::SaturatingAs;
9use nutype::nutype;
10use serde::{Deserialize, Serialize};
11
12use super::WriteError;
13
14/// A sensor resolution that fits HID++'s unsigned 16-bit DPI field.
15#[nutype(
16    const_fn,
17    derive(
18        Debug,
19        Clone,
20        Copy,
21        PartialEq,
22        Eq,
23        PartialOrd,
24        Ord,
25        From,
26        Into,
27        Display,
28        Serialize,
29        Deserialize
30    )
31)]
32pub struct Dpi(u16);
33
34impl Dpi {
35    /// Round a floating-point control value into the DPI domain.
36    #[must_use]
37    pub fn from_rounded(value: f32) -> Self {
38        Self::new(value.max(0.).round().saturating_as::<u16>())
39    }
40}
41
42impl TryFrom<u32> for Dpi {
43    type Error = TryFromIntError;
44
45    fn try_from(value: u32) -> Result<Self, Self::Error> {
46        u16::try_from(value).map(Self::new)
47    }
48}
49
50impl From<Dpi> for u32 {
51    fn from(dpi: Dpi) -> Self {
52        u32::from(dpi.into_inner())
53    }
54}
55
56impl From<Dpi> for f32 {
57    fn from(dpi: Dpi) -> Self {
58        f32::from(dpi.into_inner())
59    }
60}
61
62/// Supported DPI values reported by a device's HID++ AdjustableDpi feature.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct DpiCapabilities {
65    values: Vec<Dpi>,
66}
67
68impl DpiCapabilities {
69    /// Build capabilities from a device-reported DPI list. Values are sorted
70    /// and deduplicated so callers can rely on stable ordering.
71    pub fn new(values: Vec<u16>) -> Result<Self, WriteError> {
72        let mut values: Vec<Dpi> = values.into_iter().map(Dpi::from).collect();
73        values.sort_unstable();
74        values.dedup();
75        if values.is_empty() {
76            return Err(WriteError::EmptyDpiList);
77        }
78        Ok(Self { values })
79    }
80
81    /// All supported DPI values, sorted ascending.
82    #[must_use]
83    pub fn values(&self) -> &[Dpi] {
84        &self.values
85    }
86
87    /// Minimum supported DPI.
88    #[must_use]
89    pub fn min(&self) -> Dpi {
90        self.values[0]
91    }
92
93    /// Maximum supported DPI.
94    #[must_use]
95    pub fn max(&self) -> Dpi {
96        self.values[self.values.len() - 1]
97    }
98
99    /// Whether `dpi` is exactly supported by the device.
100    #[must_use]
101    pub fn contains(&self, dpi: Dpi) -> bool {
102        self.values.binary_search(&dpi).is_ok()
103    }
104
105    /// The supported DPI nearest to `dpi`.
106    #[must_use]
107    pub fn nearest(&self, dpi: Dpi) -> Dpi {
108        let mut nearest = self.values[0];
109        let raw_dpi = dpi.into_inner();
110        let mut best_delta = nearest.into_inner().abs_diff(raw_dpi);
111        for &candidate in &self.values[1..] {
112            let delta = candidate.into_inner().abs_diff(raw_dpi);
113            if delta < best_delta {
114                nearest = candidate;
115                best_delta = delta;
116            }
117        }
118        nearest
119    }
120
121    /// Best-effort step size for UI widgets that need a single increment.
122    /// Returns the smallest positive gap between adjacent reported values.
123    #[must_use]
124    pub fn step_hint(&self) -> Dpi {
125        self.values
126            .array_windows::<2>()
127            .filter_map(|&[low, high]| {
128                high.into_inner()
129                    .checked_sub(low.into_inner())
130                    .map(Dpi::new)
131            })
132            .filter(|step| step.into_inner() > 0)
133            .min()
134            .unwrap_or(Dpi::new(1))
135    }
136
137    /// A supported value different from `current`, for diagnostic write tests.
138    #[must_use]
139    pub fn adjacent_test_target(&self, current: Dpi) -> Option<Dpi> {
140        if self.values.len() < 2 {
141            return None;
142        }
143        match self.values.binary_search(&current) {
144            Ok(index) if index + 1 < self.values.len() => Some(self.values[index + 1]),
145            Ok(index) if index > 0 => Some(self.values[index - 1]),
146            Ok(_) => None,
147            Err(index) if index < self.values.len() => Some(self.values[index]),
148            Err(_) => self.values.last().copied(),
149        }
150        .filter(|target| *target != current)
151    }
152}
153
154/// Current DPI plus the supported values reported by the device.
155///
156/// Crosses the agent↔GUI IPC (`read_dpi`, [`DpiCapabilities`] included), so
157/// field order is wire format — changes require a `PROTOCOL_VERSION` bump
158/// (guarded by `openlogi-ipc/tests/wire_format.rs`).
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct DpiInfo {
161    /// DPI currently configured on sensor 0.
162    pub current: Dpi,
163    /// Supported values reported by the device for sensor 0.
164    pub capabilities: DpiCapabilities,
165}
166
167#[cfg(test)]
168mod tests {
169    use std::assert_matches;
170
171    use super::{Dpi, DpiCapabilities, WriteError};
172
173    #[test]
174    fn floating_control_values_round_and_stay_in_the_dpi_domain() {
175        assert_eq!(Dpi::from_rounded(1599.6), Dpi::new(1600));
176        assert_eq!(Dpi::from_rounded(-1.0), Dpi::new(0));
177        assert_eq!(Dpi::from_rounded(70_000.0), Dpi::new(u16::MAX));
178    }
179
180    #[test]
181    fn capabilities_sort_and_deduplicate_values() -> Result<(), WriteError> {
182        let caps = DpiCapabilities::new(vec![1600, 400, 800, 800])?;
183
184        assert_eq!(
185            caps.values(),
186            [Dpi::new(400), Dpi::new(800), Dpi::new(1600)]
187        );
188        assert_eq!(caps.min(), Dpi::new(400));
189        assert_eq!(caps.max(), Dpi::new(1600));
190        Ok(())
191    }
192
193    #[test]
194    fn capabilities_reject_empty_list() {
195        assert_matches!(
196            DpiCapabilities::new(Vec::new()),
197            Err(WriteError::EmptyDpiList)
198        );
199    }
200
201    #[test]
202    fn nearest_returns_closest_supported_value() -> Result<(), WriteError> {
203        let caps = DpiCapabilities::new(vec![400, 800, 1600])?;
204
205        assert_eq!(caps.nearest(Dpi::new(390)), Dpi::new(400));
206        assert_eq!(caps.nearest(Dpi::new(1000)), Dpi::new(800));
207        assert_eq!(caps.nearest(Dpi::new(2000)), Dpi::new(1600));
208        Ok(())
209    }
210
211    #[test]
212    fn step_hint_returns_smallest_positive_gap() -> Result<(), WriteError> {
213        let caps = DpiCapabilities::new(vec![400, 800, 1200, 2000])?;
214
215        assert_eq!(caps.step_hint(), Dpi::new(400));
216        Ok(())
217    }
218
219    #[test]
220    fn adjacent_test_target_prefers_next_then_previous_value() -> Result<(), WriteError> {
221        let caps = DpiCapabilities::new(vec![400, 800, 1600])?;
222
223        assert_eq!(
224            caps.adjacent_test_target(Dpi::new(400)),
225            Some(Dpi::new(800))
226        );
227        assert_eq!(
228            caps.adjacent_test_target(Dpi::new(800)),
229            Some(Dpi::new(1600))
230        );
231        assert_eq!(
232            caps.adjacent_test_target(Dpi::new(1600)),
233            Some(Dpi::new(800))
234        );
235        Ok(())
236    }
237
238    #[test]
239    fn adjacent_test_target_handles_current_outside_list() -> Result<(), WriteError> {
240        let caps = DpiCapabilities::new(vec![400, 800, 1600])?;
241
242        assert_eq!(
243            caps.adjacent_test_target(Dpi::new(1000)),
244            Some(Dpi::new(1600))
245        );
246        assert_eq!(
247            caps.adjacent_test_target(Dpi::new(2000)),
248            Some(Dpi::new(1600))
249        );
250        Ok(())
251    }
252}