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}