Skip to main content

openlogi_core/
config.rs

1//! User configuration, persisted as TOML at the platform-standard config
2//! path.
3//!
4//! Per-device state (button bindings, …) lives under the
5//! [`Config::devices`] map, keyed by a stable physical-device identifier such
6//! as `"receiver:abc123:slot:2"`. Schema migrations branch on
7//! [`Config::schema_version`].
8
9use std::{collections::BTreeMap, path::Path};
10
11use serde::{Deserialize, Serialize};
12
13mod device;
14#[cfg(feature = "fs")]
15mod file;
16mod key_trigger;
17mod settings;
18
19// Stacked, not `all(test, …)`: clippy reads the combined form as a test
20// outside a test module and withdraws the `unwrap`/`expect` exemption.
21#[cfg(test)]
22#[cfg(feature = "fs")]
23mod tests;
24
25pub use device::{DeviceConfig, DeviceIdentity};
26#[cfg(feature = "fs")]
27pub use file::{ConfigError, ConfigFile};
28#[cfg(all(test, feature = "fs"))]
29use file::{backup_existing_config, config_backup_path};
30pub use key_trigger::{KeyModifiers, KeyTrigger, KeyboardConfig, ParseTriggerError};
31pub use settings::LightSettings;
32pub use settings::{
33    AppIcon, AppSettings, Appearance, AssetSourcePreference, CameraControls, Lighting,
34    SMARTSHIFT_AUTO_DISENGAGE_DEFAULT, SMARTSHIFT_MIN_AUTO_DISENGAGE, ScrollResolution, SmartShift,
35    ThumbwheelSensitivity, WheelMode,
36};
37
38use crate::binding::{
39    Action, ActionRingConfig, ActionRingIcon, ActionRingSlot, Binding, ButtonId, GestureDirection,
40    RingAction, default_binding, default_binding_for, default_gesture_binding,
41};
42use crate::hid::Dpi;
43#[cfg(feature = "fs")]
44use settings::GestureOwner;
45/// The schema version the current build produces. Bumped whenever the
46/// persisted shape or enum vocabulary changes; readers inspect this value
47/// before consuming the rest of the file.
48///
49/// v4 removes the one-gesture-button-per-device owner lock: gesture mode is a
50/// per-button fact read from the binding shape, so `gesture_owner` no longer
51/// serializes. Loading a v3-or-older file resolves the old owner and rewrites
52/// the shapes to dispatch identically
53/// (see `Config::migrate_owner_locked_gestures`); the version gate is what
54/// keeps that pass off v4 files, where several gesture-shaped buttons are a
55/// deliberate state, not a dormant leftover.
56///
57/// v3 changes the device map from model keys to physical-device keys. No v2
58/// device entries are migrated because model-scoped settings cannot be assigned
59/// safely when two identical devices exist.
60///
61/// v2 merged the per-device `button_bindings` + `gesture_bindings` maps into a
62/// single `bindings: BTreeMap<ButtonId, Binding>`. A v1 file still loads (the
63/// `RawDeviceConfig` shim folds the legacy fields) and self-heals to v2 on the
64/// next save; [`Config::load_from_path`] accepts supported versions `1` through
65/// [`SCHEMA_VERSION`] so an invalid or forward file fails loudly instead of
66/// silently losing bindings.
67pub const SCHEMA_VERSION: u32 = 4;
68
69/// Top-level config document.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct Config {
73    /// Schema version the file was written with. Compared against
74    /// [`SCHEMA_VERSION`] on load: supported older layouts migrate, while zero
75    /// and newer layouts are rejected rather than silently losing settings.
76    pub schema_version: u32,
77    /// Non-device-scoped preferences (autostart, tray, language, …).
78    #[serde(default, skip_serializing_if = "AppSettings::is_default")]
79    pub app_settings: AppSettings,
80    /// Physical config key of the carousel-selected device, persisted so a
81    /// restart restores the last view rather than always landing on the
82    /// first paired device. `None` means "fall back to the first device".
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub selected_device: Option<String>,
85    /// When set (see [`Self::ephemeral`]), [`Self::save_atomic`] is a no-op:
86    /// this config never writes the on-disk file. Never true for a loaded or
87    /// default-constructed config.
88    #[serde(skip)]
89    // Read only by the `fs` half, which is where saving happens. The field
90    // stays in every build: `Config::ephemeral()` is public API, and a field
91    // that exists conditionally is a struct whose shape depends on a feature.
92    #[cfg_attr(
93        not(feature = "fs"),
94        expect(clippy::allow_attributes, reason = "see above"),
95        allow(dead_code, reason = "only the `fs` half suppresses a save")
96    )]
97    ephemeral: bool,
98    /// Per-device state, keyed by the stable physical-device identifier
99    /// (e.g. `"receiver:abc123:slot:2"`) so two identical models never share
100    /// an entry.
101    #[serde(default)]
102    pub devices: BTreeMap<String, DeviceConfig>,
103    /// Keyboard remappings, independent of device. The function-key remapper
104    /// (M1) reads this; `#[serde(default)]` keeps older configs without a
105    /// `[keyboard]` section loading unchanged.
106    #[serde(default)]
107    pub keyboard: KeyboardConfig,
108}
109
110impl Default for Config {
111    fn default() -> Self {
112        Self {
113            schema_version: SCHEMA_VERSION,
114            app_settings: AppSettings::default(),
115            selected_device: None,
116            devices: BTreeMap::new(),
117            ephemeral: false,
118            keyboard: KeyboardConfig::default(),
119        }
120    }
121}
122
123impl Config {
124    /// A config that never touches the on-disk file: [`Self::save_atomic`] is
125    /// a no-op. For tests that drive the state layer's persistence paths —
126    /// with a default config those would overwrite the developer's real
127    /// `config.toml` with test fixtures.
128    #[must_use]
129    pub fn ephemeral() -> Self {
130        Self {
131            ephemeral: true,
132            ..Self::default()
133        }
134    }
135
136    /// Returns the bindings stored for `device_key`, or an empty map if the
137    /// device has no committed bindings yet.
138    #[must_use]
139    pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
140        self.devices
141            .get(device_key)
142            .map(|d| d.bindings.clone())
143            .unwrap_or_default()
144    }
145
146    /// Records `binding` for `button` on `device_key`, creating the device
147    /// entry if needed. Replaces the whole binding (use
148    /// [`Self::set_gesture_direction`] to edit one direction of a gesture
149    /// binding in place).
150    pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
151        self.devices
152            .entry(device_key.to_string())
153            .or_default()
154            .bindings
155            .insert(button, binding);
156    }
157
158    /// Records (or, with `action = None`, clears) the F-key `trigger` binding
159    /// in the global `[keyboard]` map. Keyboard bindings are device-agnostic —
160    /// one map applies across all keyboards — so this mirrors [`Self::set_binding`]
161    /// minus the device key.
162    pub fn set_keyboard_binding(&mut self, trigger: KeyTrigger, action: Option<Action>) {
163        match action {
164            Some(a) => {
165                self.keyboard.bindings.insert(trigger, a);
166            }
167            None => {
168                self.keyboard.bindings.remove(&trigger);
169            }
170        }
171    }
172
173    /// The global keyboard F-key bindings (read accessor).
174    #[must_use]
175    pub fn keyboard_bindings(&self) -> &BTreeMap<KeyTrigger, Action> {
176        &self.keyboard.bindings
177    }
178
179    /// Records `action` for one `direction` of `button`'s gesture binding,
180    /// creating the device entry if needed.
181    ///
182    /// A button with no binding yet is seeded from its canonical
183    /// [`default_binding_for`] — for [`ButtonId::GestureButton`] that is the full
184    /// default direction map (including a [`GestureDirection::Click`]), so the
185    /// merged map never persists a gesture binding whose click projection is a
186    /// no-op. A prior [`Binding::Single`] is upgraded to [`Binding::Gesture`],
187    /// preserving its action as the `Click` entry.
188    pub fn set_gesture_direction(
189        &mut self,
190        device_key: &str,
191        button: ButtonId,
192        direction: GestureDirection,
193        action: Action,
194    ) {
195        if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
196            map.insert(direction, action);
197        }
198    }
199
200    /// Ensure `button` on `device_key` is a [`Binding::Gesture`], creating the
201    /// device + a default binding if needed and upgrading a [`Binding::Single`]
202    /// in place (its action kept as the [`GestureDirection::Click`]). Returns the
203    /// entry so the caller can finish it — seed every direction
204    /// ([`Binding::fill_gesture_defaults`]) or set just one. Shared by
205    /// [`Self::set_gesture_mode`] and [`Self::set_gesture_direction`] so the two
206    /// promote a button into gesture mode identically.
207    fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
208        let entry = self
209            .devices
210            .entry(device_key.to_string())
211            .or_default()
212            .bindings
213            .entry(button)
214            .or_insert_with(|| default_binding_for(button));
215        entry.upgrade_to_gesture();
216        entry
217    }
218
219    /// The single button the pre-v4 owner-locked runtime would have dispatched
220    /// gestures from, inferred from the binding shapes — the owner-lock-era
221    /// resolution rule, retained solely for
222    /// [`Self::migrate_owner_locked_gestures`]. `None` means gestures were off.
223    #[cfg(feature = "fs")]
224    fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
225        // An OS-hook button left in gesture mode took the role over.
226        if let Some((id, _)) = bindings
227            .iter()
228            .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
229        {
230            return Some(*id);
231        }
232        // A dedicated HID++ gesture button explicitly demoted to a single action means gestures off.
233        if matches!(
234            bindings.get(&ButtonId::GestureButton),
235            Some(Binding::Single(_))
236        ) {
237            return None;
238        }
239        // Default: the dedicated HID++ gesture button owns the gesture role.
240        Some(ButtonId::GestureButton)
241    }
242
243    /// Whether `button` on `device_key` is in gesture mode — a per-button fact
244    /// read straight from the binding shape: a stored [`Binding::Gesture`], or
245    /// no stored binding on a button whose canonical default
246    /// ([`default_binding_for`]) is gesture-shaped (the dedicated HID++ gesture
247    /// button starts in gesture mode).
248    ///
249    /// Gesture mode is not exclusive: any number of buttons may gesture at
250    /// once, each with its own direction map. This replaces the former
251    /// one-gesture-button-per-device owner lock — see [`Self::set_gesture_mode`].
252    #[must_use]
253    pub fn is_gesture_mode(&self, device_key: &str, button: ButtonId) -> bool {
254        self.devices
255            .get(device_key)
256            .and_then(|d| d.bindings.get(&button))
257            .map_or_else(
258                || default_binding_for(button).is_gesture(),
259                Binding::is_gesture,
260            )
261    }
262
263    /// Every button of `device_key` currently in gesture mode, in [`ButtonId`]
264    /// declaration order. Purely config-derived: callers cross it with the
265    /// device's actual controls (a model without the dedicated gesture button
266    /// simply never captures it).
267    #[must_use]
268    pub fn gesture_mode_buttons(&self, device_key: &str) -> Vec<ButtonId> {
269        ButtonId::ALL
270            .iter()
271            .copied()
272            .filter(|b| self.is_gesture_mode(device_key, *b))
273            .collect()
274    }
275
276    /// Turn gesture mode on or off for one button, independently of every
277    /// other button.
278    ///
279    /// On: restore the button's stashed map when one exists (see
280    /// [`DeviceConfig::disabled_gestures`]) — an off/on round trip hands back
281    /// the user's customized arms exactly. Otherwise promote the stored
282    /// binding in place ([`Binding::upgrade_to_gesture`] keeps a prior single
283    /// action as the [`GestureDirection::Click`] entry) and seed unbound
284    /// directions from [`default_gesture_binding`].
285    ///
286    /// Off: stash the live map, then demote to a [`Binding::Single`] of the
287    /// map's `Click` action, falling back to the button's canonical
288    /// [`default_binding`] when the map has no explicit `Click` — a demoted
289    /// button always keeps a meaningful press. A button gesturing only by
290    /// default (no stored binding) stashes its seeded default map and is
291    /// pinned off with an explicit `Single` at its canonical default, which
292    /// the capture layer leaves native.
293    pub fn set_gesture_mode(&mut self, device_key: &str, button: ButtonId, enabled: bool) {
294        if enabled {
295            let device = self.devices.entry(device_key.to_string()).or_default();
296            if let Some(map) = device.disabled_gestures.remove(&button) {
297                device.bindings.insert(button, Binding::Gesture(map));
298            } else {
299                self.ensure_gesture_binding(device_key, button)
300                    .fill_gesture_defaults();
301            }
302            return;
303        }
304        let device = self.devices.entry(device_key.to_string()).or_default();
305        match device.bindings.get_mut(&button) {
306            Some(binding) => {
307                if let Binding::Gesture(map) = binding {
308                    device.disabled_gestures.insert(button, map.clone());
309                }
310                binding.demote_to_single(default_binding(button));
311            }
312            None => {
313                if default_binding_for(button).is_gesture() {
314                    device.disabled_gestures.insert(
315                        button,
316                        GestureDirection::ALL
317                            .iter()
318                            .copied()
319                            .map(|d| (d, default_gesture_binding(d)))
320                            .collect(),
321                    );
322                    device
323                        .bindings
324                        .insert(button, Binding::Single(default_binding(button)));
325                }
326            }
327        }
328    }
329
330    /// One-time load migration for owner-locked files (`schema_version <= 3`).
331    ///
332    /// Under the owner lock at most one button dispatched gestures; every other
333    /// gesture-capable button could keep a dormant direction map awaiting
334    /// re-selection, with [`DeviceConfig::gesture_owner`] recording the choice
335    /// (absent = infer). The shape-driven model has no dormant state — a stored
336    /// [`Binding::Gesture`] IS gesture mode — so this resolves the old owner
337    /// and rewrites the shapes to dispatch exactly what the old config did:
338    ///
339    /// - the owner keeps its gesture map. A HID++ owner whose stored binding
340    ///   is absent or `Single`-shaped gets the seeded default direction map
341    ///   materialized: the v3 runtime seeded at projection time and dispatched
342    ///   that map regardless of the stored shape, so leaving the shape
343    ///   non-gesture would silently lose gestures in the rewritten file. (An
344    ///   OS-hook owner is different — the v3 hook only dispatched a stored
345    ///   gesture map, so a `Single` owner stays single.)
346    /// - every other gesture-shaped binding is stashed into
347    ///   [`DeviceConfig::disabled_gestures`] — keeping the owner-lock model's
348    ///   restore-on-reselection promise — and demotes to a [`Binding::Single`]
349    ///   of its `Click`, the only part of a dormant map the old runtime
350    ///   dispatched;
351    /// - a non-owner dedicated gesture button with no stored binding is pinned
352    ///   with an explicit `Single` at its canonical default (absence would
353    ///   re-enter gesture mode under the gesture-shaped default), which the
354    ///   capture layer leaves native;
355    /// - the consumed `gesture_owner` never serializes again — the shape is
356    ///   the whole truth from here on.
357    #[cfg(feature = "fs")]
358    fn migrate_owner_locked_gestures(&mut self) {
359        for device in self.devices.values_mut() {
360            let owner = match device.gesture_owner.take() {
361                Some(GestureOwner::Off) => None,
362                Some(GestureOwner::Button(id)) => Some(id),
363                None => Self::infer_gesture_owner(&device.bindings),
364            };
365            for (id, binding) in &mut device.bindings {
366                if Some(*id) != owner {
367                    if let Binding::Gesture(map) = binding {
368                        device.disabled_gestures.insert(*id, map.clone());
369                    }
370                    binding.demote_to_single(default_binding(*id));
371                }
372            }
373            if let Some(owner) = owner
374                && owner.is_hidpp_gesture_source()
375            {
376                let seeded = || {
377                    Binding::Gesture(
378                        GestureDirection::ALL
379                            .iter()
380                            .copied()
381                            .map(|d| (d, default_gesture_binding(d)))
382                            .collect(),
383                    )
384                };
385                match device.bindings.get_mut(&owner) {
386                    // A stored non-gesture shape is replaced by the map v3
387                    // actually dispatched.
388                    Some(binding) if !binding.is_gesture() => *binding = seeded(),
389                    Some(_) => {}
390                    // An absent owner only needs materializing when its
391                    // canonical default is not gesture-shaped (the haptic
392                    // panel); an absent dedicated button already means
393                    // default gesture mode.
394                    None => {
395                        if !default_binding_for(owner).is_gesture() {
396                            device.bindings.insert(owner, seeded());
397                        }
398                    }
399                }
400            }
401            if owner != Some(ButtonId::GestureButton) {
402                device
403                    .bindings
404                    .entry(ButtonId::GestureButton)
405                    .or_insert_with(|| Binding::Single(default_binding(ButtonId::GestureButton)));
406            }
407        }
408    }
409
410    /// Resolve the effective binding map for `device_key`, overlaying the
411    /// per-app entry for `bundle_id` (if any) on top of the global per-device
412    /// `bindings`. A per-app override replaces the whole button with a
413    /// [`Binding::Single`]; everything else falls through.
414    ///
415    /// Returns an empty map when the device has no recorded bindings yet.
416    /// Callers (the GUI / hook) layer their own defaults on top.
417    #[must_use]
418    pub fn effective_bindings(
419        &self,
420        device_key: &str,
421        bundle_id: Option<&str>,
422    ) -> BTreeMap<ButtonId, Binding> {
423        let Some(device) = self.devices.get(device_key) else {
424            return BTreeMap::new();
425        };
426        let mut out = device.bindings.clone();
427        if let Some(bid) = bundle_id
428            && let Some(overlay) = app_overlay(&device.per_app_bindings, bid)
429        {
430            for (k, v) in overlay {
431                out.insert(*k, Binding::Single(v.clone()));
432            }
433        }
434        out
435    }
436
437    /// Records a per-app override. Creates the device + app entries as
438    /// needed; passing an action of `None` removes the override and prunes
439    /// the empty app map.
440    pub fn set_per_app_binding(
441        &mut self,
442        device_key: &str,
443        bundle_id: &str,
444        button: ButtonId,
445        action: Option<Action>,
446    ) {
447        let entry = self
448            .devices
449            .entry(device_key.to_string())
450            .or_default()
451            .per_app_bindings
452            .entry(bundle_id.to_string())
453            .or_default();
454        match action {
455            Some(a) => {
456                entry.insert(button, a);
457            }
458            None => {
459                entry.remove(&button);
460            }
461        }
462        if let Some(d) = self.devices.get_mut(device_key) {
463            d.per_app_bindings.retain(|_, m| !m.is_empty());
464        }
465    }
466
467    /// Actions Ring settings for `device_key`, falling back to defaults when
468    /// the device has no saved ring configuration.
469    #[must_use]
470    pub fn action_ring(&self, device_key: &str) -> ActionRingConfig {
471        self.devices
472            .get(device_key)
473            .map(|device| device.action_ring.clone())
474            .unwrap_or_default()
475    }
476
477    /// Enable or disable `device_key`'s Actions Ring.
478    pub fn set_action_ring_enabled(&mut self, device_key: &str, enabled: bool) {
479        self.devices
480            .entry(device_key.to_string())
481            .or_default()
482            .action_ring
483            .enabled = enabled;
484    }
485
486    /// Enable or disable ring hover and activation haptics.
487    pub fn set_action_ring_haptics(&mut self, device_key: &str, enabled: bool) {
488        self.devices
489            .entry(device_key.to_string())
490            .or_default()
491            .action_ring
492            .haptics = enabled;
493    }
494
495    /// Replace or clear one slot in the default Actions Ring layout.
496    pub fn set_action_ring_slot(
497        &mut self,
498        device_key: &str,
499        slot: ActionRingSlot,
500        action: Option<RingAction>,
501    ) {
502        self.devices
503            .entry(device_key.to_string())
504            .or_default()
505            .action_ring
506            .default
507            .set_action(slot, action);
508    }
509
510    /// Set or restore the action-derived icon for one default ring slot.
511    pub fn set_action_ring_icon(
512        &mut self,
513        device_key: &str,
514        slot: ActionRingSlot,
515        icon: Option<ActionRingIcon>,
516    ) {
517        self.devices
518            .entry(device_key.to_string())
519            .or_default()
520            .action_ring
521            .default
522            .set_icon(slot, icon);
523    }
524
525    /// HID++ config key of the carousel-selected device, if any.
526    #[must_use]
527    pub fn selected_device(&self) -> Option<&str> {
528        self.selected_device.as_deref()
529    }
530
531    /// Update the carousel-selected device. Pass `None` to clear the
532    /// selection (e.g. when the previously-selected device disappears).
533    pub fn set_selected_device(&mut self, key: Option<String>) {
534        self.selected_device = key;
535    }
536
537    /// The ordered DPI preset list for `device_key`, or an empty `Vec` if the
538    /// device has none configured yet.
539    #[must_use]
540    pub fn dpi_presets(&self, device_key: &str) -> Vec<Dpi> {
541        self.devices
542            .get(device_key)
543            .map(|d| d.dpi_presets.clone())
544            .unwrap_or_default()
545    }
546
547    /// Replace the DPI preset list for `device_key`. Pass an empty `Vec` to
548    /// clear (the device block is kept; the field is just omitted on save
549    /// thanks to `skip_serializing_if`).
550    pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<Dpi>) {
551        self.devices
552            .entry(device_key.to_string())
553            .or_default()
554            .dpi_presets = presets;
555    }
556
557    /// The last-known [`DeviceIdentity`] for `device_key`, or `None` if the
558    /// device has never been seen online (or was configured before identities
559    /// were recorded).
560    #[must_use]
561    pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
562        self.devices
563            .get(device_key)
564            .and_then(|d| d.identity.as_ref())
565    }
566
567    /// Record (or refresh) the identity captured for `device_key` while it was
568    /// online, creating the device entry if needed.
569    pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
570        self.devices
571            .entry(device_key.to_string())
572            .or_default()
573            .identity = Some(identity.without_unit_identifiers());
574    }
575
576    /// Whether `device_key` has a non-empty per-app binding overlay for the
577    /// foreground app `app` (bundle id). Drives the menu-bar popover's "override
578    /// active" badge — when the current app has its own bindings for this
579    /// device, the global bindings are (partly) overridden.
580    #[must_use]
581    pub fn has_app_override(&self, device_key: &str, app: &str) -> bool {
582        self.devices.get(device_key).is_some_and(|d| {
583            app_overlay(&d.per_app_bindings, app).is_some_and(|overlay| !overlay.is_empty())
584        })
585    }
586
587    /// Iterate every device we've recorded an identity for, as
588    /// `(config_key, identity)`. Used to seed offline placeholder cards so a
589    /// known device stays visible (with its panels) before any live probe.
590    pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
591        self.devices
592            .iter()
593            .filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
594    }
595
596    /// The lighting config for `device_key`, or `None` if unset.
597    #[must_use]
598    pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
599        self.devices
600            .get(device_key)
601            .and_then(|d| d.lighting.clone())
602    }
603
604    /// Replace the lighting config for `device_key`.
605    pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
606        self.devices
607            .entry(device_key.to_string())
608            .or_default()
609            .lighting = Some(lighting);
610    }
611
612    /// The saved UVC image controls for `device_key`, or `None` if never set.
613    #[must_use]
614    pub fn camera_controls(&self, device_key: &str) -> Option<CameraControls> {
615        self.devices
616            .get(device_key)
617            .and_then(|d| d.camera_controls.clone())
618    }
619
620    /// Replace the saved UVC image controls for `device_key`.
621    pub fn set_camera_controls(&mut self, device_key: &str, controls: CameraControls) {
622        self.devices
623            .entry(device_key.to_string())
624            .or_default()
625            .camera_controls = Some(controls);
626    }
627
628    /// The saved custom camera profiles for `device_key` (name → snapshot).
629    #[must_use]
630    pub fn camera_profiles(&self, device_key: &str) -> BTreeMap<String, CameraControls> {
631        self.devices
632            .get(device_key)
633            .map(|d| d.camera_profiles.clone())
634            .unwrap_or_default()
635    }
636
637    /// Save (or overwrite) a custom camera profile for `device_key`.
638    pub fn save_camera_profile(&mut self, device_key: &str, name: &str, snap: CameraControls) {
639        self.devices
640            .entry(device_key.to_string())
641            .or_default()
642            .camera_profiles
643            .insert(name.to_string(), snap);
644    }
645
646    /// Delete a custom camera profile, clearing the active selection if it
647    /// named it. Unknown names are a no-op.
648    pub fn delete_camera_profile(&mut self, device_key: &str, name: &str) {
649        if let Some(device) = self.devices.get_mut(device_key) {
650            device.camera_profiles.remove(name);
651            if device.camera_profile.as_deref() == Some(name) {
652                device.camera_profile = None;
653            }
654        }
655    }
656
657    /// The last-applied camera profile name for `device_key`, if any.
658    #[must_use]
659    pub fn camera_active_profile(&self, device_key: &str) -> Option<String> {
660        self.devices
661            .get(device_key)
662            .and_then(|d| d.camera_profile.clone())
663    }
664
665    /// Record which camera profile `device_key` last applied.
666    pub fn set_camera_active_profile(&mut self, device_key: &str, name: Option<String>) {
667        self.devices
668            .entry(device_key.to_string())
669            .or_default()
670            .camera_profile = name;
671    }
672
673    /// The standalone-light config for `device_key`, or `None` if unset.
674    #[must_use]
675    pub fn light(&self, device_key: &str) -> Option<LightSettings> {
676        self.devices.get(device_key).and_then(|d| d.light)
677    }
678
679    /// Replace the standalone-light config for `device_key`.
680    pub fn set_light(&mut self, device_key: &str, light: LightSettings) {
681        self.devices
682            .entry(device_key.to_string())
683            .or_default()
684            .light = Some(light);
685    }
686
687    /// The committed sensor DPI for `device_key`, or `None` if never set.
688    #[must_use]
689    pub fn dpi(&self, device_key: &str) -> Option<Dpi> {
690        self.devices.get(device_key).and_then(|d| d.dpi)
691    }
692
693    /// Record the committed sensor DPI for `device_key`, so the agent can
694    /// re-apply it when the device reconnects (#189).
695    pub fn set_dpi(&mut self, device_key: &str, dpi: Dpi) {
696        self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
697    }
698
699    /// The SmartShift wheel config for `device_key`, or `None` if never set.
700    #[must_use]
701    pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
702        self.devices.get(device_key).and_then(|d| d.smartshift)
703    }
704
705    /// The persisted keyboard Fn-lock state for `device_key`, or `None` when
706    /// the user never set one (the keyboard keeps its own state).
707    #[must_use]
708    pub fn fn_lock(&self, device_key: &str) -> Option<bool> {
709        self.devices.get(device_key).and_then(|d| d.fn_lock)
710    }
711
712    /// Record the SmartShift wheel config for `device_key`, so the agent can
713    /// re-apply it when the device reconnects (#189).
714    pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
715        self.devices
716            .entry(device_key.to_string())
717            .or_default()
718            .smartshift = Some(smartshift);
719    }
720
721    /// Whether `device_key`'s scroll wheel is inverted (issue #126). `false`
722    /// (the native direction) for an unconfigured or absent device.
723    #[must_use]
724    pub fn invert_scroll(&self, device_key: &str) -> bool {
725        self.devices
726            .get(device_key)
727            .is_some_and(|d| d.invert_scroll)
728    }
729
730    /// Set whether `device_key`'s scroll wheel is inverted. The agent reads this
731    /// on the next `ReloadConfig` and applies it in the OS hook.
732    pub fn set_invert_scroll(&mut self, device_key: &str, invert: bool) {
733        self.devices
734            .entry(device_key.to_string())
735            .or_default()
736            .invert_scroll = invert;
737    }
738
739    /// The configured wheel resolution for `device_key`, or `None` when
740    /// OpenLogi should leave the device's current resolution unchanged.
741    #[must_use]
742    pub fn scroll_resolution(&self, device_key: &str) -> Option<ScrollResolution> {
743        self.devices
744            .get(device_key)
745            .and_then(|device| device.scroll_resolution)
746    }
747
748    /// Set the wheel resolution OpenLogi should restore for `device_key`.
749    /// Passing `None` returns the device to its unmanaged default state.
750    pub fn set_scroll_resolution(
751        &mut self,
752        device_key: &str,
753        resolution: Option<ScrollResolution>,
754    ) {
755        self.devices
756            .entry(device_key.to_string())
757            .or_default()
758            .scroll_resolution = resolution;
759    }
760
761    /// Whether OpenLogi manages `device_key` at all (capture + volatile
762    /// re-apply). Unconfigured devices are managed.
763    #[must_use]
764    pub fn device_enabled(&self, device_key: &str) -> bool {
765        self.devices.get(device_key).is_none_or(|d| d.enabled)
766    }
767
768    /// Enable or disable OpenLogi's management of `device_key`.
769    pub fn set_device_enabled(&mut self, device_key: &str, enabled: bool) {
770        self.devices
771            .entry(device_key.to_string())
772            .or_default()
773            .enabled = enabled;
774    }
775
776    /// The effective thumb-wheel sensitivity for `device_key`: the device's
777    /// override when set, else the app-wide default.
778    #[must_use]
779    pub fn thumbwheel_sensitivity(&self, device_key: &str) -> ThumbwheelSensitivity {
780        self.devices
781            .get(device_key)
782            .and_then(|d| d.thumbwheel_sensitivity)
783            .unwrap_or(self.app_settings.thumbwheel_sensitivity)
784    }
785
786    /// Set (or clear, with `None`) `device_key`'s thumb-wheel sensitivity
787    /// override.
788    pub fn set_device_thumbwheel_sensitivity(
789        &mut self,
790        device_key: &str,
791        sensitivity: Option<ThumbwheelSensitivity>,
792    ) {
793        self.devices
794            .entry(device_key.to_string())
795            .or_default()
796            .thumbwheel_sensitivity = sensitivity;
797    }
798}
799
800/// Resolve the most specific application overlay for a foreground identifier.
801///
802/// Exact keys retain precedence. On Windows the foreground identifier is a
803/// lower-cased executable path, so `exe:<filename>` provides a stable fallback
804/// for Store and self-updating applications whose install directory changes
805/// between versions. Recognizing both path separators keeps hand-authored
806/// Windows config inspectable on every platform without changing macOS bundle
807/// identifiers or Linux application classes.
808fn app_overlay<'a, T>(overlays: &'a BTreeMap<String, T>, app: &str) -> Option<&'a T> {
809    overlays.get(app).or_else(|| {
810        let executable_name = app.rsplit(['\\', '/']).next()?;
811        if executable_name.is_empty()
812            || !Path::new(executable_name)
813                .extension()
814                .is_some_and(|ext| ext.eq_ignore_ascii_case("exe"))
815        {
816            return None;
817        }
818
819        overlays.get(&format!("exe:{}", executable_name.to_ascii_lowercase()))
820    })
821}