Skip to main content

openlogi_core/config/
settings.rs

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