Skip to main content

openlogi_core/config/
settings.rs

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