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 identity;
17mod key_trigger;
18mod settings;
19
20// Stacked, not `all(test, …)`: clippy reads the combined form as a test
21// outside a test module and withdraws the `unwrap`/`expect` exemption.
22#[cfg(test)]
23#[cfg(feature = "fs")]
24mod tests;
25
26pub use device::{DeviceConfig, DeviceIdentity, LinkConfig, LinkOverrides};
27#[cfg(feature = "fs")]
28pub use file::{ConfigError, ConfigFile};
29#[cfg(all(test, feature = "fs"))]
30use file::{backup_existing_config, config_backup_path};
31pub use identity::canonical_device_key;
32pub use key_trigger::{KeyModifiers, KeyTrigger, KeyboardConfig, ParseTriggerError};
33pub use settings::LightSettings;
34pub use settings::{
35    AppIcon, AppSettings, Appearance, AssetSourcePreference, CameraControls, DeviceViewMode,
36    Lighting, SMARTSHIFT_AUTO_DISENGAGE_DEFAULT, SMARTSHIFT_MIN_AUTO_DISENGAGE, ScrollResolution,
37    SmartShift, ThumbwheelSensitivity, UiScale, VerticalScrollSensitivity, WheelMode,
38};
39
40use crate::binding::{
41    Action, ActionRingConfig, ActionRingIcon, ActionRingSlot, Binding, ButtonId, GestureDirection,
42    RingAction, default_binding, default_binding_for, default_gesture_binding,
43};
44use crate::device_order::PhysicalDeviceKey;
45use crate::hid::Dpi;
46#[cfg(feature = "fs")]
47use settings::GestureOwner;
48/// The schema version the current build produces. Bumped whenever the
49/// persisted shape or enum vocabulary changes; readers inspect this value
50/// before consuming the rest of the file.
51///
52/// v7 aligns the thumb-wheel scroll defaults with its normalised physical
53/// direction. Pre-v7 explicit default pairs are migrated in device and
54/// per-application profiles so they remain native rather than becoming a
55/// reversal (see `Config::migrate_thumbwheel_native_direction`).
56///
57/// v6 adds threshold-based `{ short = ..., long = ... }` button bindings.
58///
59/// v5 also drops the transport prefix from `direct:` keys: `direct:046d:c08d:unit:6be9d300`
60/// names the mouse *and the cable it was plugged into*, so a device moved to a
61/// different route was silently orphaned from its settings.
62/// [`Config::migrate_transport_scoped_keys`] rewrites such a key to its bare
63/// identity fragment (`unit:6be9d300`) — including `selected_device` and every
64/// `host_switch_targets` entry — and keeps the dropped route as a
65/// [`DeviceConfig::links`] entry. `receiver:` keys are left alone: nothing on
66/// disk says which device occupies a pairing slot, so those are folded at
67/// runtime instead, on the next online sighting (see `adopt_route`).
68///
69/// v5 adds the app-wide `ui_scale` preference. Older files default to the
70/// standard 100% scale.
71///
72/// Per-device custom names and the Home gallery view preference are optional
73/// and did not require a version bump: absent fields use the model name and
74/// responsive grid respectively.
75///
76/// v4 removes the one-gesture-button-per-device owner lock: gesture mode is a
77/// per-button fact read from the binding shape, so `gesture_owner` no longer
78/// serializes. Loading a v3-or-older file resolves the old owner and rewrites
79/// the shapes to dispatch identically
80/// (see `Config::migrate_owner_locked_gestures`); the version gate is what
81/// keeps that pass off v4 files, where several gesture-shaped buttons are a
82/// deliberate state, not a dormant leftover.
83///
84/// v3 changes the device map from model keys to physical-device keys. No v2
85/// device entries are migrated because model-scoped settings cannot be assigned
86/// safely when two identical devices exist.
87///
88/// v2 merged the per-device `button_bindings` + `gesture_bindings` maps into a
89/// single `bindings: BTreeMap<ButtonId, Binding>`. A v1 file still loads (the
90/// `RawDeviceConfig` shim folds the legacy fields) and self-heals to v2 on the
91/// next save; [`Config::load_from_path`] accepts supported versions `1` through
92/// [`SCHEMA_VERSION`] so an invalid or forward file fails loudly instead of
93/// silently losing bindings.
94pub const SCHEMA_VERSION: u32 = 7;
95
96/// Top-level config document.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct Config {
100    /// Schema version the file was written with. Compared against
101    /// [`SCHEMA_VERSION`] on load: supported older layouts migrate, while zero
102    /// and newer layouts are rejected rather than silently losing settings.
103    pub schema_version: u32,
104    /// Non-device-scoped preferences (autostart, tray, language, …).
105    #[serde(default, skip_serializing_if = "AppSettings::is_default")]
106    pub app_settings: AppSettings,
107    /// Physical config key of the active device, persisted so a
108    /// restart restores the last view rather than always landing on the
109    /// first paired device. `None` means "fall back to the first device".
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub selected_device: Option<String>,
112    /// When set (see [`Self::ephemeral`]), [`Self::save_atomic`] is a no-op:
113    /// this config never writes the on-disk file. Never true for a loaded or
114    /// default-constructed config.
115    #[serde(skip)]
116    // Read only by the `fs` half, which is where saving happens. The field
117    // stays in every build: `Config::ephemeral()` is public API, and a field
118    // that exists conditionally is a struct whose shape depends on a feature.
119    #[cfg_attr(
120        not(feature = "fs"),
121        expect(clippy::allow_attributes, reason = "see above"),
122        allow(dead_code, reason = "only the `fs` half suppresses a save")
123    )]
124    ephemeral: bool,
125    /// Per-device state, normally keyed by the stable physical-device
126    /// identifier (e.g. `"receiver:abc123:slot:2"`). A serial-less camera's
127    /// custom name instead uses its OS capture id so same-model cameras remain
128    /// distinguishable.
129    #[serde(default)]
130    pub devices: BTreeMap<String, DeviceConfig>,
131    /// Keyboard remappings, independent of device. The function-key remapper
132    /// (M1) reads this; `#[serde(default)]` keeps older configs without a
133    /// `[keyboard]` section loading unchanged.
134    #[serde(default)]
135    pub keyboard: KeyboardConfig,
136}
137
138impl Default for Config {
139    fn default() -> Self {
140        Self {
141            schema_version: SCHEMA_VERSION,
142            app_settings: AppSettings::default(),
143            selected_device: None,
144            devices: BTreeMap::new(),
145            ephemeral: false,
146            keyboard: KeyboardConfig::default(),
147        }
148    }
149}
150
151impl Config {
152    /// A config that never touches the on-disk file: [`Self::save_atomic`] is
153    /// a no-op. For tests that drive the state layer's persistence paths —
154    /// with a default config those would overwrite the developer's real
155    /// `config.toml` with test fixtures.
156    #[must_use]
157    pub fn ephemeral() -> Self {
158        Self {
159            ephemeral: true,
160            ..Self::default()
161        }
162    }
163
164    /// Returns the bindings stored for `device_key`, or an empty map if the
165    /// device has no committed bindings yet.
166    #[must_use]
167    pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
168        self.devices
169            .get(device_key)
170            .map(|d| d.bindings.clone())
171            .unwrap_or_default()
172    }
173
174    /// Records `binding` for `button` on `device_key`, creating the device
175    /// entry if needed. Replaces the whole binding (use
176    /// [`Self::set_gesture_direction`] to edit one direction of a gesture
177    /// binding in place).
178    pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
179        self.devices
180            .entry(device_key.to_string())
181            .or_default()
182            .bindings
183            .insert(button, binding);
184    }
185
186    /// Records (or, with `action = None`, clears) the F-key `trigger` binding
187    /// in the global `[keyboard]` map. Keyboard bindings are device-agnostic —
188    /// one map applies across all keyboards — so this mirrors [`Self::set_binding`]
189    /// minus the device key.
190    pub fn set_keyboard_binding(&mut self, trigger: KeyTrigger, action: Option<Action>) {
191        match action {
192            Some(a) => {
193                self.keyboard.bindings.insert(trigger, a);
194            }
195            None => {
196                self.keyboard.bindings.remove(&trigger);
197            }
198        }
199    }
200
201    /// The global keyboard F-key bindings (read accessor).
202    #[must_use]
203    pub fn keyboard_bindings(&self) -> &BTreeMap<KeyTrigger, Action> {
204        &self.keyboard.bindings
205    }
206
207    /// Records `action` for one `direction` of `button`'s gesture binding,
208    /// creating the device entry if needed.
209    ///
210    /// A button with no binding yet is seeded from its canonical
211    /// [`default_binding_for`] — for [`ButtonId::GestureButton`] that is the full
212    /// default direction map (including a [`GestureDirection::Click`]), so the
213    /// merged map never persists a gesture binding whose click projection is a
214    /// no-op. A prior [`Binding::Single`] is upgraded to [`Binding::Gesture`],
215    /// preserving its action as the `Click` entry.
216    pub fn set_gesture_direction(
217        &mut self,
218        device_key: &str,
219        button: ButtonId,
220        direction: GestureDirection,
221        action: Action,
222    ) {
223        if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
224            map.insert(direction, action);
225        }
226    }
227
228    /// Ensure `button` on `device_key` is a [`Binding::Gesture`], creating the
229    /// device + a default binding if needed and upgrading a [`Binding::Single`]
230    /// in place (its action kept as the [`GestureDirection::Click`]). Returns the
231    /// entry so the caller can finish it — seed every direction
232    /// ([`Binding::fill_gesture_defaults`]) or set just one. Shared by
233    /// [`Self::set_gesture_mode`] and [`Self::set_gesture_direction`] so the two
234    /// promote a button into gesture mode identically.
235    fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
236        let entry = self
237            .devices
238            .entry(device_key.to_string())
239            .or_default()
240            .bindings
241            .entry(button)
242            .or_insert_with(|| default_binding_for(button));
243        entry.upgrade_to_gesture();
244        entry
245    }
246
247    /// The single button the pre-v4 owner-locked runtime would have dispatched
248    /// gestures from, inferred from the binding shapes — the owner-lock-era
249    /// resolution rule, retained solely for
250    /// [`Self::migrate_owner_locked_gestures`]. `None` means gestures were off.
251    #[cfg(feature = "fs")]
252    fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
253        // An OS-hook button left in gesture mode took the role over.
254        if let Some((id, _)) = bindings
255            .iter()
256            .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
257        {
258            return Some(*id);
259        }
260        // A dedicated HID++ gesture button explicitly assigned non-gesture
261        // behavior means gestures were off.
262        if matches!(
263            bindings.get(&ButtonId::GestureButton),
264            Some(Binding::Single(_) | Binding::LongPress(_))
265        ) {
266            return None;
267        }
268        // Default: the dedicated HID++ gesture button owns the gesture role.
269        Some(ButtonId::GestureButton)
270    }
271
272    /// Whether `button` on `device_key` is in gesture mode — a per-button fact
273    /// read straight from the binding shape: a stored [`Binding::Gesture`], or
274    /// no stored binding on a button whose canonical default
275    /// ([`default_binding_for`]) is gesture-shaped (the dedicated HID++ gesture
276    /// button starts in gesture mode).
277    ///
278    /// Gesture mode is not exclusive: any number of buttons may gesture at
279    /// once, each with its own direction map. This replaces the former
280    /// one-gesture-button-per-device owner lock — see [`Self::set_gesture_mode`].
281    #[must_use]
282    pub fn is_gesture_mode(&self, device_key: &str, button: ButtonId) -> bool {
283        self.devices
284            .get(device_key)
285            .and_then(|d| d.bindings.get(&button))
286            .map_or_else(
287                || default_binding_for(button).is_gesture(),
288                Binding::is_gesture,
289            )
290    }
291
292    /// Every button of `device_key` currently in gesture mode, in [`ButtonId`]
293    /// declaration order. Purely config-derived: callers cross it with the
294    /// device's actual controls (a model without the dedicated gesture button
295    /// simply never captures it).
296    #[must_use]
297    pub fn gesture_mode_buttons(&self, device_key: &str) -> Vec<ButtonId> {
298        ButtonId::ALL
299            .iter()
300            .copied()
301            .filter(|b| self.is_gesture_mode(device_key, *b))
302            .collect()
303    }
304
305    /// Turn gesture mode on or off for one button, independently of every
306    /// other button.
307    ///
308    /// On: restore the button's stashed map when one exists (see
309    /// [`DeviceConfig::disabled_gestures`]) — an off/on round trip hands back
310    /// the user's customized arms exactly. Otherwise promote the stored
311    /// binding in place ([`Binding::upgrade_to_gesture`] keeps a prior single
312    /// action as the [`GestureDirection::Click`] entry) and seed unbound
313    /// directions from [`default_gesture_binding`].
314    ///
315    /// Off: stash the live map, then demote to a [`Binding::Single`] of the
316    /// map's `Click` action, falling back to the button's canonical
317    /// [`default_binding`] when the map has no explicit `Click` — a demoted
318    /// button always keeps a meaningful press. A button gesturing only by
319    /// default (no stored binding) stashes its seeded default map and is
320    /// pinned off with an explicit `Single` at its canonical default, which
321    /// the capture layer leaves native.
322    pub fn set_gesture_mode(&mut self, device_key: &str, button: ButtonId, enabled: bool) {
323        if enabled {
324            let device = self.devices.entry(device_key.to_string()).or_default();
325            if let Some(map) = device.disabled_gestures.remove(&button) {
326                device.bindings.insert(button, Binding::Gesture(map));
327            } else {
328                self.ensure_gesture_binding(device_key, button)
329                    .fill_gesture_defaults();
330            }
331            return;
332        }
333        let device = self.devices.entry(device_key.to_string()).or_default();
334        match device.bindings.get_mut(&button) {
335            Some(binding) => {
336                if let Binding::Gesture(map) = binding {
337                    device.disabled_gestures.insert(button, map.clone());
338                }
339                binding.demote_to_single(default_binding(button));
340            }
341            None => {
342                if default_binding_for(button).is_gesture() {
343                    device.disabled_gestures.insert(
344                        button,
345                        GestureDirection::ALL
346                            .iter()
347                            .copied()
348                            .map(|d| (d, default_gesture_binding(d)))
349                            .collect(),
350                    );
351                    device
352                        .bindings
353                        .insert(button, Binding::Single(default_binding(button)));
354                }
355            }
356        }
357    }
358
359    /// One-time load migration for owner-locked files (`schema_version <= 3`).
360    ///
361    /// Under the owner lock at most one button dispatched gestures; every other
362    /// gesture-capable button could keep a dormant direction map awaiting
363    /// re-selection, with [`DeviceConfig::gesture_owner`] recording the choice
364    /// (absent = infer). The shape-driven model has no dormant state — a stored
365    /// [`Binding::Gesture`] IS gesture mode — so this resolves the old owner
366    /// and rewrites the shapes to dispatch exactly what the old config did:
367    ///
368    /// - the owner keeps its gesture map. A HID++ owner whose stored binding
369    ///   is absent or `Single`-shaped gets the seeded default direction map
370    ///   materialized: the v3 runtime seeded at projection time and dispatched
371    ///   that map regardless of the stored shape, so leaving the shape
372    ///   non-gesture would silently lose gestures in the rewritten file. (An
373    ///   OS-hook owner is different — the v3 hook only dispatched a stored
374    ///   gesture map, so a `Single` owner stays single.)
375    /// - every other gesture-shaped binding is stashed into
376    ///   [`DeviceConfig::disabled_gestures`] — keeping the owner-lock model's
377    ///   restore-on-reselection promise — and demotes to a [`Binding::Single`]
378    ///   of its `Click`, the only part of a dormant map the old runtime
379    ///   dispatched;
380    /// - a non-owner dedicated gesture button with no stored binding is pinned
381    ///   with an explicit `Single` at its canonical default (absence would
382    ///   re-enter gesture mode under the gesture-shaped default), which the
383    ///   capture layer leaves native;
384    /// - the consumed `gesture_owner` never serializes again — the shape is
385    ///   the whole truth from here on.
386    #[cfg(feature = "fs")]
387    fn migrate_owner_locked_gestures(&mut self) {
388        for device in self.devices.values_mut() {
389            let owner = match device.gesture_owner.take() {
390                Some(GestureOwner::Off) => None,
391                Some(GestureOwner::Button(id)) => Some(id),
392                None => Self::infer_gesture_owner(&device.bindings),
393            };
394            for (id, binding) in &mut device.bindings {
395                if Some(*id) != owner {
396                    if let Binding::Gesture(map) = binding {
397                        device.disabled_gestures.insert(*id, map.clone());
398                    }
399                    binding.demote_to_single(default_binding(*id));
400                }
401            }
402            if let Some(owner) = owner
403                && owner.is_hidpp_gesture_source()
404            {
405                let seeded = || {
406                    Binding::Gesture(
407                        GestureDirection::ALL
408                            .iter()
409                            .copied()
410                            .map(|d| (d, default_gesture_binding(d)))
411                            .collect(),
412                    )
413                };
414                match device.bindings.get_mut(&owner) {
415                    // A stored non-gesture shape is replaced by the map v3
416                    // actually dispatched.
417                    Some(binding) if !binding.is_gesture() => *binding = seeded(),
418                    Some(_) => {}
419                    // An absent owner only needs materializing when its
420                    // canonical default is not gesture-shaped (the haptic
421                    // panel); an absent dedicated button already means
422                    // default gesture mode.
423                    None => {
424                        if !default_binding_for(owner).is_gesture() {
425                            device.bindings.insert(owner, seeded());
426                        }
427                    }
428                }
429            }
430            if owner != Some(ButtonId::GestureButton) {
431                device
432                    .bindings
433                    .entry(ButtonId::GestureButton)
434                    .or_insert_with(|| Binding::Single(default_binding(ButtonId::GestureButton)));
435            }
436        }
437    }
438
439    /// Rewrite v4 transport-scoped direct keys to identity keys.
440    ///
441    /// `direct:046d:c08d:unit:6be9d300` names one mouse *and the cable it was
442    /// plugged into*; `unit:6be9d300` names the mouse. The route it came from
443    /// is kept as a link so the index survives the rename. Receiver keys are
444    /// left alone — nothing on disk says which device is in a pairing slot, so
445    /// they are folded at runtime instead (see `adopt_route`).
446    ///
447    /// A `direct:` key can appear three ways: as a device's own map key, as
448    /// `selected_device`, or inside another device's `host_switch_targets` —
449    /// and the last of those can name a device with no `[devices.…]` table of
450    /// its own (nothing but the reference survives). The rename is computed
451    /// once over every occurrence so all three are rewritten consistently,
452    /// not just the ones that also own a device entry.
453    ///
454    /// Two entries can rename onto the same key — one mouse reached over both
455    /// USB and Bluetooth-direct has a v4 entry per route — so the second one
456    /// is folded in rather than inserted over the first. That is the one case
457    /// where this pass would otherwise not be lossless.
458    pub fn migrate_transport_scoped_keys(&mut self) {
459        // A v4 direct key is `direct:<vid>:<pid>:<identity-kind>:<identity>`.
460        // Splitting off the two leading id fields recovers the route to keep
461        // and the identity fragment that becomes the new key.
462        let parse_rename = |key: &str| -> Option<(String, String)> {
463            let rest = key.strip_prefix("direct:")?;
464            let mut parts = rest.splitn(3, ':');
465            let vendor = parts.next()?;
466            let product = parts.next()?;
467            let identity = parts.next()?;
468            PhysicalDeviceKey::parse(identity)?;
469            Some((identity.to_string(), format!("direct:{vendor}:{product}")))
470        };
471
472        let renames: BTreeMap<String, (String, String)> = self
473            .devices
474            .keys()
475            .cloned()
476            .chain(self.selected_device.iter().cloned())
477            .chain(
478                self.devices
479                    .values()
480                    .flat_map(|device| device.host_switch_targets.iter().cloned()),
481            )
482            .filter_map(|key| {
483                let renamed = parse_rename(&key)?;
484                Some((key, renamed))
485            })
486            .collect();
487
488        for (old, (new, route)) in &renames {
489            let Some(mut device) = self.devices.remove(old) else {
490                continue;
491            };
492            device.links.entry(route.clone()).or_default();
493            // One device reached on two direct routes — an MX Master 3S over
494            // USB and over Bluetooth-direct — has two v4 entries that rename
495            // to the same identity key. Inserting would drop whichever lost
496            // the `BTreeMap` ordering, bindings and all; folding is what
497            // makes this phase lossless, and it is the same merge adoption
498            // performs at runtime, so the second entry's disagreements land
499            // as overrides on the route they were set for.
500            match self.devices.get_mut(new) {
501                Some(existing) => identity::fold(existing, device, route),
502                None => {
503                    self.devices.insert(new.clone(), device);
504                }
505            }
506        }
507        if let Some(new) = self
508            .selected_device
509            .as_deref()
510            .and_then(|old| renames.get(old))
511            .map(|(new, _)| new.clone())
512        {
513            self.selected_device = Some(new);
514        }
515        for device in self.devices.values_mut() {
516            for target in &mut device.host_switch_targets {
517                if let Some((new, _)) = renames.get(target) {
518                    *target = new.clone();
519                }
520            }
521        }
522    }
523
524    /// Preserve the native behavior of explicit pre-v7 thumb-wheel defaults.
525    ///
526    /// Before v7 the built-in pair was forward/up → right and backward/down →
527    /// left. Because that pair equaled the defaults, an explicit copy in either
528    /// a device or per-app profile kept the wheel native. v7 swaps the defaults
529    /// after normalising device polarity; leaving an old pair in place would
530    /// now request a real reversal. Rewrite only profiles whose effective pair
531    /// was exactly the old default. Mixed/custom pairs already had literal
532    /// diverted semantics and must remain untouched.
533    #[cfg(feature = "fs")]
534    fn migrate_thumbwheel_native_direction(&mut self) {
535        let old_up = Action::HorizontalScrollRight;
536        let old_down = Action::HorizontalScrollLeft;
537        let new_up = Action::HorizontalScrollLeft;
538        let new_down = Action::HorizontalScrollRight;
539        let is_old_default = |binding: Option<&Binding>, old: &Action| match binding {
540            None => true,
541            Some(Binding::Single(action)) => action == old,
542            Some(Binding::Gesture(_) | Binding::LongPress(_)) => false,
543        };
544
545        for device in self.devices.values_mut() {
546            let global_up_is_old =
547                is_old_default(device.bindings.get(&ButtonId::ThumbwheelScrollUp), &old_up);
548            let global_down_is_old = is_old_default(
549                device.bindings.get(&ButtonId::ThumbwheelScrollDown),
550                &old_down,
551            );
552
553            for overlay in device.per_app_bindings.values_mut() {
554                let overrides_direction = overlay.contains_key(&ButtonId::ThumbwheelScrollUp)
555                    || overlay.contains_key(&ButtonId::ThumbwheelScrollDown);
556                let effective_up_is_old = overlay
557                    .get(&ButtonId::ThumbwheelScrollUp)
558                    .map_or(global_up_is_old, |action| action == &old_up);
559                let effective_down_is_old = overlay
560                    .get(&ButtonId::ThumbwheelScrollDown)
561                    .map_or(global_down_is_old, |action| action == &old_down);
562                if overrides_direction && effective_up_is_old && effective_down_is_old {
563                    // Write the complete pair. This also handles a profile that
564                    // reached the old defaults by overriding one half of a
565                    // custom global pair and inheriting the other half.
566                    overlay.insert(ButtonId::ThumbwheelScrollUp, new_up.clone());
567                    overlay.insert(ButtonId::ThumbwheelScrollDown, new_down.clone());
568                }
569            }
570
571            if global_up_is_old && global_down_is_old {
572                if let Some(Binding::Single(action)) =
573                    device.bindings.get_mut(&ButtonId::ThumbwheelScrollUp)
574                {
575                    *action = new_up.clone();
576                }
577                if let Some(Binding::Single(action)) =
578                    device.bindings.get_mut(&ButtonId::ThumbwheelScrollDown)
579                {
580                    *action = new_down.clone();
581                }
582            }
583        }
584    }
585
586    /// Resolve the effective binding map for `device_key`, overlaying the
587    /// per-app entry for `bundle_id` (if any) on top of the global per-device
588    /// `bindings`. A per-app override replaces the whole button with a
589    /// [`Binding::Single`]; everything else falls through.
590    ///
591    /// Returns an empty map when the device has no recorded bindings yet.
592    /// Callers (the GUI / hook) layer their own defaults on top.
593    #[must_use]
594    pub fn effective_bindings(
595        &self,
596        device_key: &str,
597        bundle_id: Option<&str>,
598    ) -> BTreeMap<ButtonId, Binding> {
599        let Some(device) = self.devices.get(device_key) else {
600            return BTreeMap::new();
601        };
602        let mut out = device.bindings.clone();
603        if let Some(bid) = bundle_id
604            && let Some(overlay) = app_overlay(&device.per_app_bindings, bid)
605        {
606            for (k, v) in overlay {
607                out.insert(*k, Binding::Single(v.clone()));
608            }
609        }
610        out
611    }
612
613    /// Records a per-app override. Creates the device + app entries as
614    /// needed; passing an action of `None` removes the override and prunes
615    /// the empty app map.
616    pub fn set_per_app_binding(
617        &mut self,
618        device_key: &str,
619        bundle_id: &str,
620        button: ButtonId,
621        action: Option<Action>,
622    ) {
623        let entry = self
624            .devices
625            .entry(device_key.to_string())
626            .or_default()
627            .per_app_bindings
628            .entry(bundle_id.to_string())
629            .or_default();
630        match action {
631            Some(a) => {
632                entry.insert(button, a);
633            }
634            None => {
635                entry.remove(&button);
636            }
637        }
638        if let Some(d) = self.devices.get_mut(device_key) {
639            d.per_app_bindings.retain(|_, m| !m.is_empty());
640        }
641    }
642
643    /// The overrides `device_key` stores for the application key `app`,
644    /// or `None` when it has no profile for it.
645    ///
646    /// Exact key, deliberately: this answers "what did the user author under
647    /// this key", which is what an editor needs to show and to clear. The
648    /// question [`Self::has_app_override`] answers — "will the app in front hit
649    /// a profile" — is the matcher's, and goes through the same `exe:` fallback
650    /// the matcher does. The two look interchangeable and are not.
651    #[must_use]
652    pub fn per_app_overrides(
653        &self,
654        device_key: &str,
655        app: &str,
656    ) -> Option<&BTreeMap<ButtonId, Action>> {
657        self.devices
658            .get(device_key)?
659            .per_app_bindings
660            .get(app)
661            .filter(|overrides| !overrides.is_empty())
662    }
663
664    /// Every application key `device_key` has a profile for, in key order.
665    pub fn app_profiles(&self, device_key: &str) -> impl Iterator<Item = &str> {
666        self.devices
667            .get(device_key)
668            .into_iter()
669            .flat_map(|device| device.per_app_bindings.keys().map(String::as_str))
670    }
671
672    /// Drop `device_key`'s whole profile for `app`. Nothing happens when there
673    /// is none.
674    pub fn remove_app_profile(&mut self, device_key: &str, app: &str) {
675        if let Some(device) = self.devices.get_mut(device_key) {
676            device.per_app_bindings.remove(app);
677        }
678    }
679
680    /// Actions Ring settings for `device_key`, falling back to defaults when
681    /// the device has no saved ring configuration.
682    #[must_use]
683    pub fn action_ring(&self, device_key: &str) -> ActionRingConfig {
684        self.devices
685            .get(device_key)
686            .map(|device| device.action_ring.clone())
687            .unwrap_or_default()
688    }
689
690    /// Enable or disable `device_key`'s Actions Ring.
691    pub fn set_action_ring_enabled(&mut self, device_key: &str, enabled: bool) {
692        self.devices
693            .entry(device_key.to_string())
694            .or_default()
695            .action_ring
696            .enabled = enabled;
697    }
698
699    /// Enable or disable ring hover and activation haptics.
700    pub fn set_action_ring_haptics(&mut self, device_key: &str, enabled: bool) {
701        self.devices
702            .entry(device_key.to_string())
703            .or_default()
704            .action_ring
705            .haptics = enabled;
706    }
707
708    /// Replace or clear one slot in the default Actions Ring layout.
709    pub fn set_action_ring_slot(
710        &mut self,
711        device_key: &str,
712        slot: ActionRingSlot,
713        action: Option<RingAction>,
714    ) {
715        self.devices
716            .entry(device_key.to_string())
717            .or_default()
718            .action_ring
719            .default
720            .set_action(slot, action);
721    }
722
723    /// Set or restore the action-derived icon for one default ring slot.
724    pub fn set_action_ring_icon(
725        &mut self,
726        device_key: &str,
727        slot: ActionRingSlot,
728        icon: Option<ActionRingIcon>,
729    ) {
730        self.devices
731            .entry(device_key.to_string())
732            .or_default()
733            .action_ring
734            .default
735            .set_icon(slot, icon);
736    }
737
738    /// HID++ config key of the active device, if any.
739    #[must_use]
740    pub fn selected_device(&self) -> Option<&str> {
741        self.selected_device.as_deref()
742    }
743
744    /// Update the active device. Pass `None` to clear the
745    /// selection (e.g. when the previously-selected device disappears).
746    pub fn set_selected_device(&mut self, key: Option<String>) {
747        self.selected_device = key;
748    }
749
750    /// The ordered DPI preset list for `device_key`, or an empty `Vec` if the
751    /// device has none configured yet.
752    #[must_use]
753    pub fn dpi_presets(&self, device_key: &str) -> Vec<Dpi> {
754        self.devices
755            .get(device_key)
756            .map(|d| d.dpi_presets.clone())
757            .unwrap_or_default()
758    }
759
760    /// Replace the DPI preset list for `device_key`. Pass an empty `Vec` to
761    /// clear (the device block is kept; the field is just omitted on save
762    /// thanks to `skip_serializing_if`).
763    pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<Dpi>) {
764        self.devices
765            .entry(device_key.to_string())
766            .or_default()
767            .dpi_presets = presets;
768    }
769
770    /// The last-known [`DeviceIdentity`] for `device_key`, or `None` if the
771    /// device has never been seen online (or was configured before identities
772    /// were recorded).
773    #[must_use]
774    pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
775        self.devices
776            .get(device_key)
777            .and_then(|d| d.identity.as_ref())
778    }
779
780    /// Record (or refresh) the identity captured for `device_key` while it was
781    /// online, creating the device entry if needed.
782    pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
783        self.devices
784            .entry(device_key.to_string())
785            .or_default()
786            .identity = Some(identity.without_unit_identifiers());
787    }
788
789    /// Drop everything recorded for `device_key` — identity, custom name, and
790    /// per-device settings. Returns whether an entry existed.
791    pub fn remove_device(&mut self, device_key: &str) -> bool {
792        self.devices.remove(device_key).is_some()
793    }
794
795    /// The user-assigned name for `device_key`, if one is configured.
796    #[must_use]
797    pub fn device_custom_name(&self, device_key: &str) -> Option<&str> {
798        self.devices
799            .get(device_key)
800            .and_then(|device| device.custom_name.as_deref())
801    }
802
803    /// Set the user-assigned name for `device_key`, or clear it to use the
804    /// hardware model name again.
805    pub fn set_device_custom_name(&mut self, device_key: &str, custom_name: Option<String>) {
806        self.devices
807            .entry(device_key.to_string())
808            .or_default()
809            .custom_name = custom_name;
810    }
811
812    /// Whether `device_key` has a non-empty per-app binding overlay for the
813    /// foreground app `app` (bundle id). Drives the menu-bar popover's "override
814    /// active" badge — when the current app has its own bindings for this
815    /// device, the global bindings are (partly) overridden.
816    #[must_use]
817    pub fn has_app_override(&self, device_key: &str, app: &str) -> bool {
818        self.devices.get(device_key).is_some_and(|d| {
819            app_overlay(&d.per_app_bindings, app).is_some_and(|overlay| !overlay.is_empty())
820        })
821    }
822
823    /// Iterate every device we've recorded an identity for, as
824    /// `(config_key, identity)`. Used to seed offline placeholder cards so a
825    /// known device stays visible (with its panels) before any live probe.
826    pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
827        self.devices
828            .iter()
829            .filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
830    }
831
832    /// The lighting config for `device_key`, or `None` if unset.
833    #[must_use]
834    pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
835        self.devices
836            .get(device_key)
837            .and_then(|d| d.lighting.clone())
838    }
839
840    /// Replace the lighting config for `device_key`.
841    pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
842        self.devices
843            .entry(device_key.to_string())
844            .or_default()
845            .lighting = Some(lighting);
846    }
847
848    /// The saved UVC image controls for `device_key`, or `None` if never set.
849    #[must_use]
850    pub fn camera_controls(&self, device_key: &str) -> Option<CameraControls> {
851        self.devices
852            .get(device_key)
853            .and_then(|d| d.camera_controls.clone())
854    }
855
856    /// Replace the saved UVC image controls for `device_key`.
857    pub fn set_camera_controls(&mut self, device_key: &str, controls: CameraControls) {
858        self.devices
859            .entry(device_key.to_string())
860            .or_default()
861            .camera_controls = Some(controls);
862    }
863
864    /// The saved custom camera profiles for `device_key` (name → snapshot).
865    #[must_use]
866    pub fn camera_profiles(&self, device_key: &str) -> BTreeMap<String, CameraControls> {
867        self.devices
868            .get(device_key)
869            .map(|d| d.camera_profiles.clone())
870            .unwrap_or_default()
871    }
872
873    /// Save (or overwrite) a custom camera profile for `device_key`.
874    pub fn save_camera_profile(&mut self, device_key: &str, name: &str, snap: CameraControls) {
875        self.devices
876            .entry(device_key.to_string())
877            .or_default()
878            .camera_profiles
879            .insert(name.to_string(), snap);
880    }
881
882    /// Delete a custom camera profile, clearing the active selection if it
883    /// named it. Unknown names are a no-op.
884    pub fn delete_camera_profile(&mut self, device_key: &str, name: &str) {
885        if let Some(device) = self.devices.get_mut(device_key) {
886            device.camera_profiles.remove(name);
887            if device.camera_profile.as_deref() == Some(name) {
888                device.camera_profile = None;
889            }
890        }
891    }
892
893    /// The last-applied camera profile name for `device_key`, if any.
894    #[must_use]
895    pub fn camera_active_profile(&self, device_key: &str) -> Option<String> {
896        self.devices
897            .get(device_key)
898            .and_then(|d| d.camera_profile.clone())
899    }
900
901    /// Record which camera profile `device_key` last applied.
902    pub fn set_camera_active_profile(&mut self, device_key: &str, name: Option<String>) {
903        self.devices
904            .entry(device_key.to_string())
905            .or_default()
906            .camera_profile = name;
907    }
908
909    /// The standalone-light config for `device_key`, or `None` if unset.
910    #[must_use]
911    pub fn light(&self, device_key: &str) -> Option<LightSettings> {
912        self.devices.get(device_key).and_then(|d| d.light)
913    }
914
915    /// Replace the standalone-light config for `device_key`.
916    pub fn set_light(&mut self, device_key: &str, light: LightSettings) {
917        self.devices
918            .entry(device_key.to_string())
919            .or_default()
920            .light = Some(light);
921    }
922
923    /// The committed sensor DPI for `device_key`, or `None` if never set.
924    #[must_use]
925    pub fn dpi(&self, device_key: &str) -> Option<Dpi> {
926        self.devices.get(device_key).and_then(|d| d.dpi)
927    }
928
929    /// Record the committed sensor DPI for `device_key`, so the agent can
930    /// re-apply it when the device reconnects (#189).
931    pub fn set_dpi(&mut self, device_key: &str, dpi: Dpi) {
932        self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
933    }
934
935    /// The SmartShift wheel config for `device_key`, or `None` if never set.
936    #[must_use]
937    pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
938        self.devices.get(device_key).and_then(|d| d.smartshift)
939    }
940
941    /// The persisted keyboard Fn-lock state for `device_key`, or `None` when
942    /// the user never set one (the keyboard keeps its own state).
943    #[must_use]
944    pub fn fn_lock(&self, device_key: &str) -> Option<bool> {
945        self.devices.get(device_key).and_then(|d| d.fn_lock)
946    }
947
948    /// Record the SmartShift wheel config for `device_key`, so the agent can
949    /// re-apply it when the device reconnects (#189).
950    pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
951        self.devices
952            .entry(device_key.to_string())
953            .or_default()
954            .smartshift = Some(smartshift);
955    }
956
957    /// Whether `device_key`'s scroll wheel is inverted (issue #126). `false`
958    /// (the native direction) for an unconfigured or absent device.
959    #[must_use]
960    pub fn invert_scroll(&self, device_key: &str) -> bool {
961        self.devices
962            .get(device_key)
963            .is_some_and(|d| d.invert_scroll)
964    }
965
966    /// Set whether `device_key`'s scroll wheel is inverted. The agent reads this
967    /// on the next `ReloadConfig` and applies it in the OS hook.
968    pub fn set_invert_scroll(&mut self, device_key: &str, invert: bool) {
969        self.devices
970            .entry(device_key.to_string())
971            .or_default()
972            .invert_scroll = invert;
973    }
974
975    /// The configured wheel resolution for `device_key`, or `None` when
976    /// OpenLogi should leave the device's current resolution unchanged.
977    #[must_use]
978    pub fn scroll_resolution(&self, device_key: &str) -> Option<ScrollResolution> {
979        self.devices
980            .get(device_key)
981            .and_then(|device| device.scroll_resolution)
982    }
983
984    /// Set the wheel resolution OpenLogi should restore for `device_key`.
985    /// Passing `None` returns the device to its unmanaged default state.
986    pub fn set_scroll_resolution(
987        &mut self,
988        device_key: &str,
989        resolution: Option<ScrollResolution>,
990    ) {
991        self.devices
992            .entry(device_key.to_string())
993            .or_default()
994            .scroll_resolution = resolution;
995    }
996
997    /// Whether OpenLogi manages `device_key` at all (capture + volatile
998    /// re-apply). Unconfigured devices are managed.
999    #[must_use]
1000    pub fn device_enabled(&self, device_key: &str) -> bool {
1001        self.devices.get(device_key).is_none_or(|d| d.enabled)
1002    }
1003
1004    /// Enable or disable OpenLogi's management of `device_key`.
1005    pub fn set_device_enabled(&mut self, device_key: &str, enabled: bool) {
1006        self.devices
1007            .entry(device_key.to_string())
1008            .or_default()
1009            .enabled = enabled;
1010    }
1011
1012    /// The effective thumb-wheel sensitivity for `device_key`: the device's
1013    /// override when set, else the app-wide default.
1014    #[must_use]
1015    pub fn thumbwheel_sensitivity(&self, device_key: &str) -> ThumbwheelSensitivity {
1016        self.devices
1017            .get(device_key)
1018            .and_then(|d| d.thumbwheel_sensitivity)
1019            .unwrap_or(self.app_settings.thumbwheel_sensitivity)
1020    }
1021
1022    /// Set (or clear, with `None`) `device_key`'s thumb-wheel sensitivity
1023    /// override.
1024    pub fn set_device_thumbwheel_sensitivity(
1025        &mut self,
1026        device_key: &str,
1027        sensitivity: Option<ThumbwheelSensitivity>,
1028    ) {
1029        self.devices
1030            .entry(device_key.to_string())
1031            .or_default()
1032            .thumbwheel_sensitivity = sensitivity;
1033    }
1034}
1035
1036/// Resolve the most specific application overlay for a foreground identifier.
1037///
1038/// Exact keys retain precedence. On Windows the foreground identifier is a
1039/// lower-cased executable path, so `exe:<filename>` provides a stable fallback
1040/// for Store and self-updating applications whose install directory changes
1041/// between versions. Recognizing both path separators keeps hand-authored
1042/// Windows config inspectable on every platform without changing macOS bundle
1043/// identifiers or Linux application classes.
1044fn app_overlay<'a, T>(overlays: &'a BTreeMap<String, T>, app: &str) -> Option<&'a T> {
1045    overlays.get(app).or_else(|| {
1046        let executable_name = app.rsplit(['\\', '/']).next()?;
1047        if executable_name.is_empty()
1048            || !Path::new(executable_name)
1049                .extension()
1050                .is_some_and(|ext| ext.eq_ignore_ascii_case("exe"))
1051        {
1052            return None;
1053        }
1054
1055        overlays.get(&format!("exe:{}", executable_name.to_ascii_lowercase()))
1056    })
1057}