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 the frontend that can — on macOS the GUI hands the choice to
144 /// the Dock and writes it onto the bundle, so the icon survives a quit;
145 /// elsewhere it is inert. Defaults to the icon the app is signed with.
146 #[serde(default)]
147 pub app_icon: AppIcon,
148 /// Whether the GUI automatically downloads device images from
149 /// `assets.openlogi.org` when a device appears. `true` (default) keeps
150 /// the current behavior; `false` makes no asset network requests at all
151 /// (the app falls back to bundled art and the synthetic silhouette). A
152 /// manual "Refresh assets" in Settings still fetches on demand regardless.
153 /// Whether the GUI automatically downloads device images from the selected
154 /// source when a device appears. `true` (default) keeps the current behavior;
155 /// `false` makes no asset network requests at all (the app falls back to
156 /// bundled art and the synthetic silhouette). A manual "Refresh assets" in
157 /// Settings still fetches on demand regardless.
158 #[serde(default = "default_true")]
159 pub auto_download_assets: bool,
160 /// Preferred mirror for automatic and manual device-asset downloads.
161 /// Defaults to racing all built-in mirrors; `OPENLOGI_ASSETS` remains a
162 /// process-level override for development and diagnostics.
163 #[serde(default)]
164 pub asset_source: AssetSourcePreference,
165 /// UI language as a BCP-47-ish locale code matching the GUI's bundled
166 /// locales (e.g. `"en"`, `"de"`, `"pt-BR"`, `"zh-CN"`, `"zh-TW"`; see the
167 /// GUI's `i18n::SUPPORTED`). `None` means "follow the system locale", which
168 /// the GUI resolves at startup. Stored here so a user's explicit choice
169 /// survives restarts regardless of the OS setting.
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub language: Option<String>,
172 /// Thumb-wheel responsiveness. It scales both the speed of the wheel's
173 /// continuous horizontal scroll and how few rotation increments a custom
174 /// wheel action needs to fire. [`ThumbwheelSensitivity::DEFAULT`] means 1×
175 /// scroll speed; the wheel is only diverted from native scrolling once
176 /// this leaves the default.
177 #[serde(default)]
178 pub thumbwheel_sensitivity: ThumbwheelSensitivity,
179 /// Light/dark appearance preference. Defaults to following the OS.
180 #[serde(default)]
181 pub appearance: Appearance,
182 /// Name of the theme used in light mode (a [`crate`]-agnostic string
183 /// matching a gpui-component theme, e.g. `"OpenLogi Light"`). `None` uses
184 /// the OpenLogi brand light theme.
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub theme_light: Option<String>,
187 /// Name of the theme used in dark mode. `None` uses the OpenLogi brand dark
188 /// theme.
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub theme_dark: Option<String>,
191 /// Corner-radius override for the UI, in pixels (the Appearance page offers
192 /// `0` / `6` / `12`). `None` keeps each theme's own radius.
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub ui_radius: Option<u8>,
195}
196
197/// Thumb-wheel responsiveness on OpenLogi's `1..=100` scale.
198#[nutype(
199 const_fn,
200 validate(greater_or_equal = 1, less_or_equal = 100),
201 derive(
202 Debug,
203 Clone,
204 Copy,
205 PartialEq,
206 Eq,
207 PartialOrd,
208 Ord,
209 TryFrom,
210 Into,
211 Display,
212 Serialize,
213 Deserialize
214 )
215)]
216pub struct ThumbwheelSensitivity(u8);
217
218impl ThumbwheelSensitivity {
219 /// Lowest selectable sensitivity.
220 pub const MIN: Self = match Self::try_new(1) {
221 Ok(value) => value,
222 Err(_) => panic!("valid minimum thumb-wheel sensitivity"),
223 };
224 /// Highest selectable sensitivity.
225 pub const MAX: Self = match Self::try_new(100) {
226 Ok(value) => value,
227 Err(_) => panic!("valid maximum thumb-wheel sensitivity"),
228 };
229 /// Out-of-the-box sensitivity. At this value horizontal scrolling runs at
230 /// 1× and remains native unless a thumb-wheel binding is customized.
231 pub const DEFAULT: Self = match Self::try_new(14) {
232 Ok(value) => value,
233 Err(_) => panic!("valid default thumb-wheel sensitivity"),
234 };
235
236 /// Round and clamp a floating-point slider value into the valid range.
237 #[must_use]
238 pub fn from_rounded(value: f32) -> Self {
239 let value = if value.is_nan() {
240 f32::from(Self::MIN)
241 } else {
242 value
243 };
244 let raw = value
245 .clamp(f32::from(Self::MIN), f32::from(Self::MAX))
246 .round()
247 .saturating_as::<u8>();
248 let Ok(value) = Self::try_new(raw) else {
249 unreachable!("clamped thumb-wheel sensitivity is always valid");
250 };
251 value
252 }
253
254 /// Continuous-scroll speed multiplier relative to [`Self::DEFAULT`].
255 #[must_use]
256 pub fn scroll_multiplier(self) -> f32 {
257 f32::from(self) / f32::from(Self::DEFAULT)
258 }
259
260 /// Rotation increments required to fire a discrete thumb-wheel action.
261 #[must_use]
262 pub fn action_threshold(self) -> i32 {
263 (2 * i32::from(Self::DEFAULT) - i32::from(self)).max(1)
264 }
265}
266
267impl Default for ThumbwheelSensitivity {
268 fn default() -> Self {
269 Self::DEFAULT
270 }
271}
272
273impl From<ThumbwheelSensitivity> for f32 {
274 fn from(sensitivity: ThumbwheelSensitivity) -> Self {
275 Self::from(sensitivity.into_inner())
276 }
277}
278
279impl From<ThumbwheelSensitivity> for i32 {
280 fn from(sensitivity: ThumbwheelSensitivity) -> Self {
281 Self::from(sensitivity.into_inner())
282 }
283}
284
285impl AppSettings {
286 /// `skip_serializing_if` helper: true when nothing diverges from the
287 /// default, so empty settings don't clutter `config.toml`.
288 #[must_use]
289 pub fn is_default(&self) -> bool {
290 self == &Self::default()
291 }
292}
293
294impl Default for AppSettings {
295 fn default() -> Self {
296 Self {
297 launch_at_login: false,
298 check_for_updates: false,
299 auto_install_updates: false,
300 update_prompt_seen: false,
301 show_in_menu_bar: true,
302 capture_mouse_events: true,
303 auto_download_assets: true,
304 asset_source: AssetSourcePreference::Automatic,
305 language: None,
306 thumbwheel_sensitivity: ThumbwheelSensitivity::DEFAULT,
307 appearance: Appearance::System,
308 app_icon: AppIcon::Openlogi,
309 theme_light: None,
310 theme_dark: None,
311 ui_radius: None,
312 }
313 }
314}
315
316/// serde default for the on-by-default [`AppSettings`] toggles
317/// ([`AppSettings::show_in_menu_bar`], [`AppSettings::capture_mouse_events`],
318/// [`AppSettings::auto_download_assets`]), so configs predating a field keep the
319/// out-of-the-box behavior.
320fn default_true() -> bool {
321 true
322}
323
324/// Per-device RGB lighting: a single static color, brightness, and on/off.
325/// Deliberately basic — per-key effects are a later addition.
326///
327/// Crosses the agent↔GUI IPC (`set_lighting`), so field order is wire format —
328/// changes require a `PROTOCOL_VERSION` bump (guarded by
329/// `openlogi-ipc/tests/wire_format.rs`).
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(deny_unknown_fields)]
332pub struct Lighting {
333 /// Master on/off for the device's lighting. The color and brightness
334 /// persist while disabled, so re-enabling restores the previous look.
335 #[serde(default = "default_lighting_enabled")]
336 pub enabled: bool,
337 /// Static color as 6 hex digits `"RRGGBB"` (no leading `#`). A value
338 /// that does not parse is rejected with its TOML location.
339 #[serde(
340 default = "default_lighting_color",
341 deserialize_with = "deserialize_lighting_color"
342 )]
343 pub color: Rgb,
344 /// Brightness percent (`0`–`100`).
345 #[serde(
346 default = "default_lighting_brightness",
347 deserialize_with = "deserialize_brightness"
348 )]
349 pub brightness: u8,
350}
351
352/// Persisted settings for a standalone light such as Logitech Litra.
353///
354/// Brightness is stored as a normalized percentage so the same config shape
355/// works for lumen-based, percentage-based, and stepped light protocols. The
356/// selected driver maps it to its native range when applying the setting.
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(deny_unknown_fields)]
359pub struct LightSettings {
360 /// Whether the light should be on.
361 #[serde(default = "default_true")]
362 pub enabled: bool,
363 /// Link power to aggregate host-camera activity. This is a policy setting:
364 /// brightness, colour temperature, and the persisted manual power choice
365 /// remain independent from the transient effective power state.
366 #[serde(default, skip_serializing_if = "is_false")]
367 pub auto_camera: bool,
368 /// Brightness across the device's advertised range.
369 #[serde(
370 default = "default_light_brightness",
371 deserialize_with = "deserialize_brightness"
372 )]
373 pub brightness_percent: u8,
374 /// Desired colour temperature, when the device supports it.
375 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub temperature_kelvin: Option<u16>,
377 /// Optional colour for a driver that exposes RGB controls.
378 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub color: Option<Rgb>,
380}
381
382const fn default_light_brightness() -> u8 {
383 100
384}
385
386impl Default for LightSettings {
387 fn default() -> Self {
388 Self {
389 enabled: true,
390 auto_camera: false,
391 brightness_percent: default_light_brightness(),
392 temperature_kelvin: None,
393 color: None,
394 }
395 }
396}
397
398impl LightSettings {
399 /// Create settings with a normalized brightness percentage.
400 #[must_use]
401 pub fn new(enabled: bool, brightness_percent: u8, temperature_kelvin: Option<u16>) -> Self {
402 Self {
403 enabled,
404 auto_camera: false,
405 brightness_percent: brightness_percent.min(100),
406 temperature_kelvin,
407 color: None,
408 }
409 }
410}
411
412#[expect(
413 clippy::trivially_copy_pass_by_ref,
414 reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
415)]
416const fn is_false(value: &bool) -> bool {
417 !*value
418}
419
420impl Default for Lighting {
421 fn default() -> Self {
422 Self {
423 enabled: default_lighting_enabled(),
424 color: default_lighting_color(),
425 brightness: default_lighting_brightness(),
426 }
427 }
428}
429
430fn default_lighting_enabled() -> bool {
431 true
432}
433
434fn default_lighting_color() -> Rgb {
435 Rgb::WHITE
436}
437
438fn default_lighting_brightness() -> u8 {
439 100
440}
441
442/// Reject brightness outside the UI and hardware contract.
443fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
444where
445 D: serde::Deserializer<'de>,
446{
447 let value = u8::deserialize(deserializer)?;
448 if value <= 100 {
449 Ok(value)
450 } else {
451 Err(serde::de::Error::custom(format_args!(
452 "brightness must be between 0 and 100, got {value}"
453 )))
454 }
455}
456
457/// Accept the optional `#` prefix supported by older releases, then parse the
458/// validated RGB value.
459fn deserialize_lighting_color<'de, D>(deserializer: D) -> Result<Rgb, D::Error>
460where
461 D: serde::Deserializer<'de>,
462{
463 let color = String::deserialize(deserializer)?;
464 color
465 .strip_prefix('#')
466 .unwrap_or(color.as_str())
467 .parse()
468 .map_err(serde::de::Error::custom)
469}
470
471/// Per-webcam UVC controls, keyed by control name (`brightness`, `focus`,
472/// `focus_auto`, …). Each value is the raw device unit (its scale comes from
473/// the camera's own min/max); auto toggles store 0/1. Persisted so values
474/// survive an unplug or reboot — the GUI re-applies them over USB when the
475/// camera is next viewed, since the hardware only retains them until it loses
476/// power. Serializes to the same TOML table the earlier fixed-field struct
477/// wrote, so existing saved controls load unchanged.
478#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
479#[serde(transparent)]
480pub struct CameraControls(pub BTreeMap<String, i32>);
481
482/// Vertical wheel reporting resolution for HID++ `0x2121 HiResWheel`.
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
484#[serde(rename_all = "snake_case")]
485pub enum ScrollResolution {
486 /// One scroll report per physical ratchet step.
487 Low,
488 /// Finer-grained reports between physical ratchet steps.
489 High,
490}
491
492/// Scroll-wheel mode for [`SmartShift`]: free-spin or ratchet (clicky).
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
494#[serde(rename_all = "snake_case")]
495pub enum WheelMode {
496 /// Free-spin — the wheel rotates without détentes.
497 Free,
498 /// Ratchet (clicky) scrolling. With SmartShift enabled the firmware
499 /// auto-releases into free-spin past the configured
500 /// [`auto_disengage`](SmartShift::auto_disengage) speed.
501 Ratchet,
502}
503
504/// SmartShift auto-disengage out-of-box default (`16` ≈ 4 turn/s, per the
505/// x2110 / x2111 spec). The sensitivity slider's default.
506pub const SMARTSHIFT_AUTO_DISENGAGE_DEFAULT: SmartShiftThreshold =
507 match SmartShiftThreshold::try_new(16) {
508 Ok(value) => value,
509 Err(_) => panic!("valid default SmartShift threshold"),
510 };
511
512/// Smallest auto-disengage threshold OpenLogi will store or apply (`8` ≈
513/// 2 turn/s). Below this the ratchet releases into free-spin at everyday scroll
514/// speeds, leaving the wheel "stuck" spinning (#317); `0` is also the firmware
515/// "do not change" sentinel that must never be stored as a real value. A
516/// persisted threshold below this floor is rejected on load.
517pub const SMARTSHIFT_MIN_AUTO_DISENGAGE: SmartShiftThreshold = match SmartShiftThreshold::try_new(8)
518{
519 Ok(value) => value,
520 Err(_) => panic!("valid minimum SmartShift threshold"),
521};
522
523/// Reject a persisted auto-disengage threshold below the supported floor.
524fn deserialize_auto_disengage<'de, D>(deserializer: D) -> Result<SmartShiftAutoDisengage, D::Error>
525where
526 D: serde::Deserializer<'de>,
527{
528 let value = SmartShiftAutoDisengage::deserialize(deserializer)?;
529 match value {
530 SmartShiftAutoDisengage::Threshold(threshold)
531 if threshold < SMARTSHIFT_MIN_AUTO_DISENGAGE =>
532 {
533 Err(serde::de::Error::custom(format_args!(
534 "SmartShift auto_disengage must be between {SMARTSHIFT_MIN_AUTO_DISENGAGE} and 255, got {threshold}"
535 )))
536 }
537 _ => Ok(value),
538 }
539}
540
541/// Per-device SmartShift wheel configuration, persisted so the agent can
542/// re-apply it when the device reconnects: the values are written to device
543/// RAM and do not survive a power cycle (#189), despite earlier assumptions
544/// that the device kept them in NVM.
545///
546/// Config-file only — never crosses the IPC (the agent reads it from
547/// `config.toml` on reload), so it is free to evolve without a
548/// `PROTOCOL_VERSION` bump.
549#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
550#[serde(deny_unknown_fields)]
551pub struct SmartShift {
552 /// The persisted wheel mode, re-applied to device RAM on reconnect.
553 pub mode: WheelMode,
554 /// SmartShift auto-disengage threshold (`0x08`–`0xFE`, in 0.25 turn/s
555 /// steps), or `0xFF` for a permanently engaged ratchet. A persisted value
556 /// below [`SMARTSHIFT_MIN_AUTO_DISENGAGE`] is rejected on load.
557 #[serde(deserialize_with = "deserialize_auto_disengage")]
558 pub auto_disengage: SmartShiftAutoDisengage,
559 /// Firmware tunable-torque level (`1`–`255`), `0` when the device does not
560 /// expose tunable torque. HID++ defines the full non-zero byte range.
561 #[serde(with = "crate::hid::smartshift::optional_tunable_torque")]
562 pub tunable_torque: Option<TunableTorque>,
563}
564
565/// The v3-and-older owner-lock choice: which control owned a device's single
566/// gesture role. Deserialize-only since v4 — the load migration
567/// (`Config::migrate_owner_locked_gestures`) consumes it and rewrites the
568/// binding shapes, which are the whole truth from then on. Read as a bare TOML
569/// scalar (`"Off"` or a [`ButtonId`] name).
570#[derive(Clone, Copy, Debug, PartialEq, Eq)]
571pub(super) enum GestureOwner {
572 /// Gestures were explicitly turned off for this device.
573 Off,
574 /// The named button owned the gesture role.
575 Button(ButtonId),
576}
577
578/// Lenient legacy deserializer for v3-and-older `gesture_owner`. Those releases
579/// already treated an unknown value as absent and inferred the owner; preserving
580/// that behavior keeps migration compatible. Current schemas reject the field
581/// before device deserialization.
582pub(super) fn deserialize_gesture_owner<'de, D>(
583 deserializer: D,
584) -> Result<Option<GestureOwner>, D::Error>
585where
586 D: serde::Deserializer<'de>,
587{
588 let s = String::deserialize(deserializer)?;
589 if s == "Off" {
590 return Ok(Some(GestureOwner::Off));
591 }
592 // Parse the button name with a throwaway error type so an unknown token maps
593 // to `None` (infer) rather than propagating an error.
594 let button = ButtonId::deserialize(
595 serde::de::value::StrDeserializer::<serde::de::value::Error>::new(&s),
596 )
597 .ok();
598 Ok(button.map(GestureOwner::Button))
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604
605 #[test]
606 fn smartshift_rejects_values_outside_the_persisted_contract() {
607 let parse = |auto_disengage: u8, tunable_torque: u8| {
608 let body = format!(
609 "mode = \"ratchet\"\nauto_disengage = {auto_disengage}\ntunable_torque = {tunable_torque}\n"
610 );
611 toml::from_str::<SmartShift>(&body)
612 };
613 let minimum = u8::from(SMARTSHIFT_MIN_AUTO_DISENGAGE);
614 parse(minimum - 1, 50)
615 .expect_err("auto_disengage below the persisted minimum must be rejected");
616 parse(minimum, 50).expect("the minimum itself is in contract");
617 parse(0xff, 0xff).expect("the top of both ranges is in contract");
618 assert_eq!(
619 parse(minimum, 0)
620 .expect("zero torque represents unsupported hardware")
621 .tunable_torque,
622 None
623 );
624 }
625
626 #[test]
627 fn floating_thumbwheel_sensitivity_rounds_and_saturates_into_the_domain() {
628 assert_eq!(u8::from(ThumbwheelSensitivity::from_rounded(49.6)), 50);
629 assert_eq!(
630 ThumbwheelSensitivity::from_rounded(f32::NAN),
631 ThumbwheelSensitivity::MIN
632 );
633 assert_eq!(
634 ThumbwheelSensitivity::from_rounded(f32::NEG_INFINITY),
635 ThumbwheelSensitivity::MIN
636 );
637 assert_eq!(
638 ThumbwheelSensitivity::from_rounded(f32::INFINITY),
639 ThumbwheelSensitivity::MAX
640 );
641 }
642}