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