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