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}