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