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