logiops_core/features/
hires_scrolling.rs

1//! `HiRes Scrolling` feature (0x2121) - High-resolution scroll wheel configuration.
2//!
3//! This feature provides control over high-resolution scrolling behavior,
4//! including scroll direction inversion and ratchet mode detection.
5
6use bitflags::bitflags;
7use hidpp_transport::HidapiChannel;
8use tracing::{debug, trace};
9
10use crate::error::{HidppErrorCode, ProtocolError, Result};
11use crate::protocol::{build_long_request, get_error_code, is_error_response};
12
13/// Function IDs for the `HiRes` Scrolling feature.
14mod function_id {
15    /// Get wheel capabilities (multiplier, flags).
16    pub const GET_WHEEL_CAPABILITY: u8 = 0x00;
17    /// Get current wheel mode.
18    pub const GET_WHEEL_MODE: u8 = 0x01;
19    /// Set wheel mode.
20    pub const SET_WHEEL_MODE: u8 = 0x02;
21    /// Get ratchet switch state.
22    pub const GET_RATCHET_SWITCH_STATE: u8 = 0x03;
23}
24
25bitflags! {
26    /// Wheel capability flags.
27    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
28    pub struct WheelCapabilities: u8 {
29        /// Wheel has ratchet mode detection.
30        const HAS_RATCHET = 0x04;
31        /// Wheel has direction inversion capability.
32        const HAS_INVERT = 0x08;
33    }
34}
35
36bitflags! {
37    /// Wheel mode flags.
38    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
39    pub struct WheelModeFlags: u8 {
40        /// Use HID++ for scroll events (vs native HID).
41        const TARGET = 0x01;
42        /// High resolution mode enabled.
43        const RESOLUTION = 0x02;
44        /// Scroll direction inverted.
45        const INVERT = 0x04;
46    }
47}
48
49/// Wheel capability information.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct WheelInfo {
52    /// Scroll multiplier (resolution).
53    pub multiplier: u8,
54    /// Capability flags.
55    pub capabilities: WheelCapabilities,
56}
57
58/// Current wheel mode configuration.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct HiResScrollConfig {
61    /// Whether HID++ events are used for scrolling.
62    pub target: bool,
63    /// Whether high resolution mode is enabled.
64    pub hires: bool,
65    /// Whether scroll direction is inverted.
66    pub invert: bool,
67}
68
69impl Default for HiResScrollConfig {
70    fn default() -> Self {
71        Self {
72            target: false,
73            hires: true,
74            invert: false,
75        }
76    }
77}
78
79impl From<WheelModeFlags> for HiResScrollConfig {
80    fn from(flags: WheelModeFlags) -> Self {
81        Self {
82            target: flags.contains(WheelModeFlags::TARGET),
83            hires: flags.contains(WheelModeFlags::RESOLUTION),
84            invert: flags.contains(WheelModeFlags::INVERT),
85        }
86    }
87}
88
89impl From<&HiResScrollConfig> for WheelModeFlags {
90    fn from(config: &HiResScrollConfig) -> Self {
91        let mut flags = WheelModeFlags::empty();
92        if config.target {
93            flags |= WheelModeFlags::TARGET;
94        }
95        if config.hires {
96            flags |= WheelModeFlags::RESOLUTION;
97        }
98        if config.invert {
99            flags |= WheelModeFlags::INVERT;
100        }
101        flags
102    }
103}
104
105/// `HiRes Scrolling` feature implementation.
106pub struct HiResScrollingFeature {
107    device_index: u8,
108    feature_index: u8,
109}
110
111impl HiResScrollingFeature {
112    /// Creates a new hi-res scrolling feature accessor.
113    ///
114    /// # Arguments
115    /// * `device_index` - Device index (0xFF for direct)
116    /// * `feature_index` - Feature index from root feature discovery
117    #[must_use]
118    pub fn new(device_index: u8, feature_index: u8) -> Self {
119        Self {
120            device_index,
121            feature_index,
122        }
123    }
124
125    /// Gets the wheel capabilities.
126    ///
127    /// Returns the scroll multiplier and capability flags (ratchet detection, inversion).
128    ///
129    /// # Errors
130    /// Returns an error if HID++ communication fails.
131    pub async fn get_wheel_capability(&self, channel: &HidapiChannel) -> Result<WheelInfo> {
132        let request = build_long_request(
133            self.device_index,
134            self.feature_index,
135            function_id::GET_WHEEL_CAPABILITY,
136            &[],
137        );
138
139        trace!("getting wheel capability");
140        let response = channel.request(&request, 5).await?;
141
142        if is_error_response(&response) {
143            let code = get_error_code(&response).unwrap_or(0);
144            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
145        }
146
147        if response.len() < 6 {
148            return Err(ProtocolError::InvalidResponse(
149                "wheel capability response too short".to_string(),
150            ));
151        }
152
153        let multiplier = response[4];
154        let flags = response[5];
155
156        let info = WheelInfo {
157            multiplier,
158            capabilities: WheelCapabilities::from_bits_truncate(flags),
159        };
160
161        debug!(
162            multiplier = info.multiplier,
163            has_ratchet = info.capabilities.contains(WheelCapabilities::HAS_RATCHET),
164            has_invert = info.capabilities.contains(WheelCapabilities::HAS_INVERT),
165            "got wheel capability"
166        );
167
168        Ok(info)
169    }
170
171    /// Gets the current wheel mode.
172    ///
173    /// # Errors
174    /// Returns an error if HID++ communication fails.
175    pub async fn get_wheel_mode(&self, channel: &HidapiChannel) -> Result<HiResScrollConfig> {
176        let request = build_long_request(
177            self.device_index,
178            self.feature_index,
179            function_id::GET_WHEEL_MODE,
180            &[],
181        );
182
183        trace!("getting wheel mode");
184        let response = channel.request(&request, 5).await?;
185
186        if is_error_response(&response) {
187            let code = get_error_code(&response).unwrap_or(0);
188            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
189        }
190
191        if response.len() < 5 {
192            return Err(ProtocolError::InvalidResponse(
193                "wheel mode response too short".to_string(),
194            ));
195        }
196
197        let flags = WheelModeFlags::from_bits_truncate(response[4]);
198        let config = HiResScrollConfig::from(flags);
199
200        debug!(
201            target = config.target,
202            hires = config.hires,
203            invert = config.invert,
204            "got wheel mode"
205        );
206
207        Ok(config)
208    }
209
210    /// Sets the wheel mode.
211    ///
212    /// # Arguments
213    /// * `channel` - HID channel
214    /// * `config` - Scroll configuration to apply
215    ///
216    /// # Errors
217    /// Returns an error if HID++ communication fails.
218    pub async fn set_wheel_mode(
219        &self,
220        channel: &HidapiChannel,
221        config: &HiResScrollConfig,
222    ) -> Result<()> {
223        let flags = WheelModeFlags::from(config);
224        let request = build_long_request(
225            self.device_index,
226            self.feature_index,
227            function_id::SET_WHEEL_MODE,
228            &[flags.bits()],
229        );
230
231        trace!(
232            target = config.target,
233            hires = config.hires,
234            invert = config.invert,
235            "setting wheel mode"
236        );
237        let response = channel.request(&request, 5).await?;
238
239        if is_error_response(&response) {
240            let code = get_error_code(&response).unwrap_or(0);
241            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
242        }
243
244        debug!(
245            target = config.target,
246            hires = config.hires,
247            invert = config.invert,
248            "set wheel mode"
249        );
250
251        Ok(())
252    }
253
254    /// Gets the ratchet switch state.
255    ///
256    /// Returns `true` if the wheel is in ratchet (notched) mode,
257    /// `false` if in free-spin mode.
258    ///
259    /// # Errors
260    /// Returns an error if HID++ communication fails or the device
261    /// doesn't support ratchet detection.
262    pub async fn get_ratchet_switch_state(&self, channel: &HidapiChannel) -> Result<bool> {
263        let request = build_long_request(
264            self.device_index,
265            self.feature_index,
266            function_id::GET_RATCHET_SWITCH_STATE,
267            &[],
268        );
269
270        trace!("getting ratchet switch state");
271        let response = channel.request(&request, 5).await?;
272
273        if is_error_response(&response) {
274            let code = get_error_code(&response).unwrap_or(0);
275            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
276        }
277
278        if response.len() < 5 {
279            return Err(ProtocolError::InvalidResponse(
280                "ratchet state response too short".to_string(),
281            ));
282        }
283
284        let is_ratchet = (response[4] & 0x01) != 0;
285        debug!(is_ratchet, "got ratchet switch state");
286
287        Ok(is_ratchet)
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn test_wheel_mode_flags_conversion() {
297        let config = HiResScrollConfig {
298            target: true,
299            hires: true,
300            invert: false,
301        };
302        let flags = WheelModeFlags::from(&config);
303        assert!(flags.contains(WheelModeFlags::TARGET));
304        assert!(flags.contains(WheelModeFlags::RESOLUTION));
305        assert!(!flags.contains(WheelModeFlags::INVERT));
306    }
307
308    #[test]
309    fn test_config_from_flags() {
310        let flags = WheelModeFlags::RESOLUTION | WheelModeFlags::INVERT;
311        let config = HiResScrollConfig::from(flags);
312        assert!(!config.target);
313        assert!(config.hires);
314        assert!(config.invert);
315    }
316
317    #[test]
318    fn test_capability_flags() {
319        let caps = WheelCapabilities::HAS_RATCHET | WheelCapabilities::HAS_INVERT;
320        assert!(caps.contains(WheelCapabilities::HAS_RATCHET));
321        assert!(caps.contains(WheelCapabilities::HAS_INVERT));
322    }
323}