Skip to main content

openlogi_core/config/
settings.rs

1//! App-wide and per-device *value* settings: [`AppSettings`], [`Appearance`],
2//! [`Lighting`], [`ScrollResolution`], [`WheelMode`] / [`SmartShift`], and
3//! the legacy [`GestureOwner`], plus their serde helpers.
4
5use std::collections::BTreeMap;
6
7use az::SaturatingAs;
8use nutype::nutype;
9use serde::{Deserialize, Serialize};
10
11use crate::binding::ButtonId;
12use crate::color::Rgb;
13use crate::hid::{SmartShiftAutoDisengage, SmartShiftThreshold, TunableTorque};
14
15/// Light/dark appearance preference. `System` follows the OS appearance (the
16/// historical behaviour); `Light` / `Dark` force a mode regardless of the OS.
17/// Platform-free so the core crate stays GUI-agnostic — the GUI maps this onto
18/// gpui-component's `ThemeMode`.
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum Appearance {
22    /// Follow the operating system's light/dark setting.
23    #[default]
24    System,
25    /// Always use the light variant of the selected theme.
26    Light,
27    /// Always use the dark variant of the selected theme.
28    Dark,
29}
30
31/// Preferred source for on-demand device assets.
32///
33/// `Automatic` races every built-in mirror; the other variants pin a sync to
34/// one source. The GUI maps this persisted preference to the shared asset
35/// client's source type, keeping endpoint URLs and npm routing out of config.
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum AssetSourcePreference {
39    /// Use the first healthy built-in mirror.
40    #[default]
41    Automatic,
42    /// Use OpenLogi's official asset endpoint.
43    #[serde(rename = "openlogi")]
44    OpenLogi,
45    /// Use the versioned endpoint on Cloudflare's network.
46    Cloudflare,
47    /// Use the versioned npm packages through Fastly's network.
48    Fastly,
49}
50
51/// App-wide preferences not tied to any particular device.
52///
53/// All fields are `#[serde(default)]` so adding a new one is backward
54/// compatible — old config files just keep the default for the new field.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57#[allow(
58    clippy::struct_excessive_bools,
59    reason = "independent on/off user preferences, not a state machine"
60)]
61pub struct AppSettings {
62    /// When true, a macOS `LaunchAgent` plist at
63    /// `~/Library/LaunchAgents/org.openlogi.openlogi.plist` is installed
64    /// so the app starts on login (P2.2). The plist is reconciled with
65    /// this field on every startup; flipping the flag and relaunching is
66    /// enough to install / remove it.
67    #[serde(default)]
68    pub launch_at_login: bool,
69    /// Opt-in update check (P2.8). **Off by default** to honour the
70    /// README's "no telemetry, no auto-update poller" promise. When true,
71    /// the app makes exactly one `HEAD /repos/AprilNEA/OpenLogi/releases/
72    /// latest` request per launch and logs whether a newer version is
73    /// available — no automatic download.
74    #[serde(default)]
75    pub check_for_updates: bool,
76    /// Opt-in automatic install. When true *and* [`Self::check_for_updates`]
77    /// surfaces a newer version, the GUI downloads and stages it in the
78    /// background; the update is applied on the next restart (never mid-session,
79    /// and never auto-relaunched). **Off by default** — it only acts after a
80    /// check the user already opted into, and stays inert in unsigned dev builds
81    /// where verification fails closed.
82    #[serde(default)]
83    pub auto_install_updates: bool,
84    /// True once the first-run "check for updates?" prompt has been answered
85    /// (either way), so it is never shown again. The prompt is how a
86    /// privacy-conscious default of `check_for_updates = false` still lets a
87    /// user opt in on first launch.
88    #[serde(default)]
89    pub update_prompt_seen: bool,
90    /// Whether OpenLogi shows a macOS menu-bar (status item) icon — and, on
91    /// Windows, the notification-area (tray) icon. `true` (default) → the
92    /// agent is visible in the menu bar / tray; `false` → it runs with no
93    /// visible presence (macOS additionally keeps the ordinary Dock icon
94    /// while a window is open). Ignored on Linux.
95    #[serde(default = "default_true")]
96    pub show_in_menu_bar: bool,
97    /// Whether the agent installs the OS-level mouse hook (CGEventTap /
98    /// exclusive `evdev` grab / `WH_MOUSE_LL`) that intercepts mouse events
99    /// for button remapping. `true` (default) keeps remapping active;
100    /// `false` is an escape hatch that leaves every input device untouched
101    /// (on Linux: no exclusive grabs at all; on macOS the agent also skips
102    /// the startup Accessibility prompt). HID++-side features — DPI,
103    /// SmartShift, the gesture button, the thumb wheel — are unaffected.
104    /// Takes effect on agent restart.
105    #[serde(default = "default_true")]
106    pub capture_mouse_events: bool,
107    /// Whether the GUI automatically downloads device images from
108    /// `assets.openlogi.org` when a device appears. `true` (default) keeps
109    /// the current behavior; `false` makes no asset network requests at all
110    /// (the app falls back to bundled art and the synthetic silhouette). A
111    /// manual "Refresh assets" in Settings still fetches on demand regardless.
112    /// Whether the GUI automatically downloads device images from the selected
113    /// source when a device appears. `true` (default) keeps the current behavior;
114    /// `false` makes no asset network requests at all (the app falls back to
115    /// bundled art and the synthetic silhouette). A manual "Refresh assets" in
116    /// Settings still fetches on demand regardless.
117    #[serde(default = "default_true")]
118    pub auto_download_assets: bool,
119    /// Preferred mirror for automatic and manual device-asset downloads.
120    /// Defaults to racing all built-in mirrors; `OPENLOGI_ASSETS` remains a
121    /// process-level override for development and diagnostics.
122    #[serde(default)]
123    pub asset_source: AssetSourcePreference,
124    /// UI language as a BCP-47-ish locale code matching the GUI's bundled
125    /// locales (e.g. `"en"`, `"de"`, `"pt-BR"`, `"zh-CN"`, `"zh-TW"`; see the
126    /// GUI's `i18n::SUPPORTED`). `None` means "follow the system locale", which
127    /// the GUI resolves at startup. Stored here so a user's explicit choice
128    /// survives restarts regardless of the OS setting.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub language: Option<String>,
131    /// Thumb-wheel responsiveness. It scales both the speed of the wheel's
132    /// continuous horizontal scroll and how few rotation increments a custom
133    /// wheel action needs to fire. [`ThumbwheelSensitivity::DEFAULT`] means 1×
134    /// scroll speed; the wheel is only diverted from native scrolling once
135    /// this leaves the default.
136    #[serde(default)]
137    pub thumbwheel_sensitivity: ThumbwheelSensitivity,
138    /// Light/dark appearance preference. Defaults to following the OS.
139    #[serde(default)]
140    pub appearance: Appearance,
141    /// Name of the theme used in light mode (a [`crate`]-agnostic string
142    /// matching a gpui-component theme, e.g. `"OpenLogi Light"`). `None` uses
143    /// the OpenLogi brand light theme.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub theme_light: Option<String>,
146    /// Name of the theme used in dark mode. `None` uses the OpenLogi brand dark
147    /// theme.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub theme_dark: Option<String>,
150    /// Corner-radius override for the UI, in pixels (the Appearance page offers
151    /// `0` / `6` / `12`). `None` keeps each theme's own radius.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub ui_radius: Option<u8>,
154}
155
156/// Thumb-wheel responsiveness on OpenLogi's `1..=100` scale.
157#[nutype(
158    const_fn,
159    validate(greater_or_equal = 1, less_or_equal = 100),
160    derive(
161        Debug,
162        Clone,
163        Copy,
164        PartialEq,
165        Eq,
166        PartialOrd,
167        Ord,
168        TryFrom,
169        Into,
170        Display,
171        Serialize,
172        Deserialize
173    )
174)]
175pub struct ThumbwheelSensitivity(u8);
176
177impl ThumbwheelSensitivity {
178    /// Lowest selectable sensitivity.
179    pub const MIN: Self = match Self::try_new(1) {
180        Ok(value) => value,
181        Err(_) => panic!("valid minimum thumb-wheel sensitivity"),
182    };
183    /// Highest selectable sensitivity.
184    pub const MAX: Self = match Self::try_new(100) {
185        Ok(value) => value,
186        Err(_) => panic!("valid maximum thumb-wheel sensitivity"),
187    };
188    /// Out-of-the-box sensitivity. At this value horizontal scrolling runs at
189    /// 1× and remains native unless a thumb-wheel binding is customized.
190    pub const DEFAULT: Self = match Self::try_new(14) {
191        Ok(value) => value,
192        Err(_) => panic!("valid default thumb-wheel sensitivity"),
193    };
194
195    /// Round and clamp a floating-point slider value into the valid range.
196    #[must_use]
197    pub fn from_rounded(value: f32) -> Self {
198        let value = if value.is_nan() {
199            f32::from(Self::MIN)
200        } else {
201            value
202        };
203        let raw = value
204            .clamp(f32::from(Self::MIN), f32::from(Self::MAX))
205            .round()
206            .saturating_as::<u8>();
207        let Ok(value) = Self::try_new(raw) else {
208            unreachable!("clamped thumb-wheel sensitivity is always valid");
209        };
210        value
211    }
212
213    /// Continuous-scroll speed multiplier relative to [`Self::DEFAULT`].
214    #[must_use]
215    pub fn scroll_multiplier(self) -> f32 {
216        f32::from(self) / f32::from(Self::DEFAULT)
217    }
218
219    /// Rotation increments required to fire a discrete thumb-wheel action.
220    #[must_use]
221    pub fn action_threshold(self) -> i32 {
222        (2 * i32::from(Self::DEFAULT) - i32::from(self)).max(1)
223    }
224}
225
226impl Default for ThumbwheelSensitivity {
227    fn default() -> Self {
228        Self::DEFAULT
229    }
230}
231
232impl From<ThumbwheelSensitivity> for f32 {
233    fn from(sensitivity: ThumbwheelSensitivity) -> Self {
234        Self::from(sensitivity.into_inner())
235    }
236}
237
238impl From<ThumbwheelSensitivity> for i32 {
239    fn from(sensitivity: ThumbwheelSensitivity) -> Self {
240        Self::from(sensitivity.into_inner())
241    }
242}
243
244impl AppSettings {
245    /// `skip_serializing_if` helper: true when nothing diverges from the
246    /// default, so empty settings don't clutter `config.toml`.
247    #[must_use]
248    pub fn is_default(&self) -> bool {
249        self == &Self::default()
250    }
251}
252
253impl Default for AppSettings {
254    fn default() -> Self {
255        Self {
256            launch_at_login: false,
257            check_for_updates: false,
258            auto_install_updates: false,
259            update_prompt_seen: false,
260            show_in_menu_bar: true,
261            capture_mouse_events: true,
262            auto_download_assets: true,
263            asset_source: AssetSourcePreference::Automatic,
264            language: None,
265            thumbwheel_sensitivity: ThumbwheelSensitivity::DEFAULT,
266            appearance: Appearance::System,
267            theme_light: None,
268            theme_dark: None,
269            ui_radius: None,
270        }
271    }
272}
273
274/// serde default for the on-by-default [`AppSettings`] toggles
275/// ([`AppSettings::show_in_menu_bar`], [`AppSettings::capture_mouse_events`],
276/// [`AppSettings::auto_download_assets`]), so configs predating a field keep the
277/// out-of-the-box behavior.
278fn default_true() -> bool {
279    true
280}
281
282/// Per-device RGB lighting: a single static color, brightness, and on/off.
283/// Deliberately basic — per-key effects are a later addition.
284///
285/// Crosses the agent↔GUI IPC (`set_lighting`), so field order is wire format —
286/// changes require a `PROTOCOL_VERSION` bump (guarded by
287/// `openlogi-ipc/tests/wire_format.rs`).
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(deny_unknown_fields)]
290pub struct Lighting {
291    /// Master on/off for the device's lighting. The color and brightness
292    /// persist while disabled, so re-enabling restores the previous look.
293    #[serde(default = "default_lighting_enabled")]
294    pub enabled: bool,
295    /// Static color as 6 hex digits `"RRGGBB"` (no leading `#`). A value
296    /// that does not parse is rejected with its TOML location.
297    #[serde(
298        default = "default_lighting_color",
299        deserialize_with = "deserialize_lighting_color"
300    )]
301    pub color: Rgb,
302    /// Brightness percent (`0`–`100`).
303    #[serde(
304        default = "default_lighting_brightness",
305        deserialize_with = "deserialize_brightness"
306    )]
307    pub brightness: u8,
308}
309
310/// Persisted settings for a standalone light such as Logitech Litra.
311///
312/// Brightness is stored as a normalized percentage so the same config shape
313/// works for lumen-based, percentage-based, and stepped light protocols. The
314/// selected driver maps it to its native range when applying the setting.
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(deny_unknown_fields)]
317pub struct LightSettings {
318    /// Whether the light should be on.
319    #[serde(default = "default_true")]
320    pub enabled: bool,
321    /// Link power to aggregate host-camera activity. This is a policy setting:
322    /// brightness, colour temperature, and the persisted manual power choice
323    /// remain independent from the transient effective power state.
324    #[serde(default, skip_serializing_if = "is_false")]
325    pub auto_camera: bool,
326    /// Brightness across the device's advertised range.
327    #[serde(
328        default = "default_light_brightness",
329        deserialize_with = "deserialize_brightness"
330    )]
331    pub brightness_percent: u8,
332    /// Desired colour temperature, when the device supports it.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub temperature_kelvin: Option<u16>,
335    /// Optional colour for a driver that exposes RGB controls.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub color: Option<Rgb>,
338}
339
340const fn default_light_brightness() -> u8 {
341    100
342}
343
344impl Default for LightSettings {
345    fn default() -> Self {
346        Self {
347            enabled: true,
348            auto_camera: false,
349            brightness_percent: default_light_brightness(),
350            temperature_kelvin: None,
351            color: None,
352        }
353    }
354}
355
356impl LightSettings {
357    /// Create settings with a normalized brightness percentage.
358    #[must_use]
359    pub fn new(enabled: bool, brightness_percent: u8, temperature_kelvin: Option<u16>) -> Self {
360        Self {
361            enabled,
362            auto_camera: false,
363            brightness_percent: brightness_percent.min(100),
364            temperature_kelvin,
365            color: None,
366        }
367    }
368}
369
370#[allow(
371    clippy::trivially_copy_pass_by_ref,
372    reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
373)]
374const fn is_false(value: &bool) -> bool {
375    !*value
376}
377
378impl Default for Lighting {
379    fn default() -> Self {
380        Self {
381            enabled: default_lighting_enabled(),
382            color: default_lighting_color(),
383            brightness: default_lighting_brightness(),
384        }
385    }
386}
387
388fn default_lighting_enabled() -> bool {
389    true
390}
391
392fn default_lighting_color() -> Rgb {
393    Rgb::WHITE
394}
395
396fn default_lighting_brightness() -> u8 {
397    100
398}
399
400/// Reject brightness outside the UI and hardware contract.
401fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
402where
403    D: serde::Deserializer<'de>,
404{
405    let value = u8::deserialize(deserializer)?;
406    if value <= 100 {
407        Ok(value)
408    } else {
409        Err(serde::de::Error::custom(format_args!(
410            "brightness must be between 0 and 100, got {value}"
411        )))
412    }
413}
414
415/// Accept the optional `#` prefix supported by older releases, then parse the
416/// validated RGB value.
417fn deserialize_lighting_color<'de, D>(deserializer: D) -> Result<Rgb, D::Error>
418where
419    D: serde::Deserializer<'de>,
420{
421    let color = String::deserialize(deserializer)?;
422    color
423        .strip_prefix('#')
424        .unwrap_or(color.as_str())
425        .parse()
426        .map_err(serde::de::Error::custom)
427}
428
429/// Per-webcam UVC controls, keyed by control name (`brightness`, `focus`,
430/// `focus_auto`, …). Each value is the raw device unit (its scale comes from
431/// the camera's own min/max); auto toggles store 0/1. Persisted so values
432/// survive an unplug or reboot — the GUI re-applies them over USB when the
433/// camera is next viewed, since the hardware only retains them until it loses
434/// power. Serializes to the same TOML table the earlier fixed-field struct
435/// wrote, so existing saved controls load unchanged.
436#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
437#[serde(transparent)]
438pub struct CameraControls(pub BTreeMap<String, i32>);
439
440/// Vertical wheel reporting resolution for HID++ `0x2121 HiResWheel`.
441#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
442#[serde(rename_all = "snake_case")]
443pub enum ScrollResolution {
444    /// One scroll report per physical ratchet step.
445    Low,
446    /// Finer-grained reports between physical ratchet steps.
447    High,
448}
449
450/// Scroll-wheel mode for [`SmartShift`]: free-spin or ratchet (clicky).
451#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
452#[serde(rename_all = "snake_case")]
453pub enum WheelMode {
454    /// Free-spin — the wheel rotates without détentes.
455    Free,
456    /// Ratchet (clicky) scrolling. With SmartShift enabled the firmware
457    /// auto-releases into free-spin past the configured
458    /// [`auto_disengage`](SmartShift::auto_disengage) speed.
459    Ratchet,
460}
461
462/// SmartShift auto-disengage out-of-box default (`16` ≈ 4 turn/s, per the
463/// x2110 / x2111 spec). The sensitivity slider's default.
464pub const SMARTSHIFT_AUTO_DISENGAGE_DEFAULT: SmartShiftThreshold =
465    match SmartShiftThreshold::try_new(16) {
466        Ok(value) => value,
467        Err(_) => panic!("valid default SmartShift threshold"),
468    };
469
470/// Smallest auto-disengage threshold OpenLogi will store or apply (`8` ≈
471/// 2 turn/s). Below this the ratchet releases into free-spin at everyday scroll
472/// speeds, leaving the wheel "stuck" spinning (#317); `0` is also the firmware
473/// "do not change" sentinel that must never be stored as a real value. A
474/// persisted threshold below this floor is rejected on load.
475pub const SMARTSHIFT_MIN_AUTO_DISENGAGE: SmartShiftThreshold = match SmartShiftThreshold::try_new(8)
476{
477    Ok(value) => value,
478    Err(_) => panic!("valid minimum SmartShift threshold"),
479};
480
481/// Reject a persisted auto-disengage threshold below the supported floor.
482fn deserialize_auto_disengage<'de, D>(deserializer: D) -> Result<SmartShiftAutoDisengage, D::Error>
483where
484    D: serde::Deserializer<'de>,
485{
486    let value = SmartShiftAutoDisengage::deserialize(deserializer)?;
487    match value {
488        SmartShiftAutoDisengage::Threshold(threshold)
489            if threshold < SMARTSHIFT_MIN_AUTO_DISENGAGE =>
490        {
491            Err(serde::de::Error::custom(format_args!(
492                "SmartShift auto_disengage must be between {SMARTSHIFT_MIN_AUTO_DISENGAGE} and 255, got {threshold}"
493            )))
494        }
495        _ => Ok(value),
496    }
497}
498
499/// Per-device SmartShift wheel configuration, persisted so the agent can
500/// re-apply it when the device reconnects: the values are written to device
501/// RAM and do not survive a power cycle (#189), despite earlier assumptions
502/// that the device kept them in NVM.
503///
504/// Config-file only — never crosses the IPC (the agent reads it from
505/// `config.toml` on reload), so it is free to evolve without a
506/// `PROTOCOL_VERSION` bump.
507#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
508#[serde(deny_unknown_fields)]
509pub struct SmartShift {
510    /// The persisted wheel mode, re-applied to device RAM on reconnect.
511    pub mode: WheelMode,
512    /// SmartShift auto-disengage threshold (`0x08`–`0xFE`, in 0.25 turn/s
513    /// steps), or `0xFF` for a permanently engaged ratchet. A persisted value
514    /// below [`SMARTSHIFT_MIN_AUTO_DISENGAGE`] is rejected on load.
515    #[serde(deserialize_with = "deserialize_auto_disengage")]
516    pub auto_disengage: SmartShiftAutoDisengage,
517    /// Firmware tunable-torque level (`1`–`255`), `0` when the device does not
518    /// expose tunable torque. HID++ defines the full non-zero byte range.
519    #[serde(with = "crate::hid::smartshift::optional_tunable_torque")]
520    pub tunable_torque: Option<TunableTorque>,
521}
522
523/// The v3-and-older owner-lock choice: which control owned a device's single
524/// gesture role. Deserialize-only since v4 — the load migration
525/// (`Config::migrate_owner_locked_gestures`) consumes it and rewrites the
526/// binding shapes, which are the whole truth from then on. Read as a bare TOML
527/// scalar (`"Off"` or a [`ButtonId`] name).
528#[derive(Clone, Copy, Debug, PartialEq, Eq)]
529pub(super) enum GestureOwner {
530    /// Gestures were explicitly turned off for this device.
531    Off,
532    /// The named button owned the gesture role.
533    Button(ButtonId),
534}
535
536/// Lenient legacy deserializer for v3-and-older `gesture_owner`. Those releases
537/// already treated an unknown value as absent and inferred the owner; preserving
538/// that behavior keeps migration compatible. Current schemas reject the field
539/// before device deserialization.
540pub(super) fn deserialize_gesture_owner<'de, D>(
541    deserializer: D,
542) -> Result<Option<GestureOwner>, D::Error>
543where
544    D: serde::Deserializer<'de>,
545{
546    let s = String::deserialize(deserializer)?;
547    if s == "Off" {
548        return Ok(Some(GestureOwner::Off));
549    }
550    // Parse the button name with a throwaway error type so an unknown token maps
551    // to `None` (infer) rather than propagating an error.
552    let button = ButtonId::deserialize(
553        serde::de::value::StrDeserializer::<serde::de::value::Error>::new(&s),
554    )
555    .ok();
556    Ok(button.map(GestureOwner::Button))
557}
558
559#[cfg(test)]
560#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
561mod tests {
562    use super::*;
563
564    #[test]
565    fn smartshift_rejects_values_outside_the_persisted_contract() {
566        let parse = |auto_disengage: u8, tunable_torque: u8| {
567            let body = format!(
568                "mode = \"ratchet\"\nauto_disengage = {auto_disengage}\ntunable_torque = {tunable_torque}\n"
569            );
570            toml::from_str::<SmartShift>(&body)
571        };
572        let minimum = u8::from(SMARTSHIFT_MIN_AUTO_DISENGAGE);
573        parse(minimum - 1, 50)
574            .expect_err("auto_disengage below the persisted minimum must be rejected");
575        parse(minimum, 50).expect("the minimum itself is in contract");
576        parse(0xff, 0xff).expect("the top of both ranges is in contract");
577        assert_eq!(
578            parse(minimum, 0)
579                .expect("zero torque represents unsupported hardware")
580                .tunable_torque,
581            None
582        );
583    }
584
585    #[test]
586    fn floating_thumbwheel_sensitivity_rounds_and_saturates_into_the_domain() {
587        assert_eq!(u8::from(ThumbwheelSensitivity::from_rounded(49.6)), 50);
588        assert_eq!(
589            ThumbwheelSensitivity::from_rounded(f32::NAN),
590            ThumbwheelSensitivity::MIN
591        );
592        assert_eq!(
593            ThumbwheelSensitivity::from_rounded(f32::NEG_INFINITY),
594            ThumbwheelSensitivity::MIN
595        );
596        assert_eq!(
597            ThumbwheelSensitivity::from_rounded(f32::INFINITY),
598            ThumbwheelSensitivity::MAX
599        );
600    }
601}