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