openlogi_core/config/per_app.rs
1//! Per-application profiles: the button overrides a device stores for one
2//! application, and how the application in front is matched to them.
3//!
4//! An override replaces a whole button with a single action. Editing addresses
5//! a profile by its exact key; matching also falls back from a Windows
6//! executable path to that executable's `exe:<filename>` key.
7
8use std::{collections::BTreeMap, path::Path};
9
10use super::Config;
11use crate::binding::{Action, Binding, ButtonId};
12
13impl Config {
14 /// Resolve the effective binding map for `device_key`, overlaying the
15 /// per-app entry for `bundle_id` (if any) on top of the global per-device
16 /// `bindings`. A per-app override replaces the whole button with a
17 /// [`Binding::Single`]; everything else falls through.
18 ///
19 /// Returns an empty map when the device has no recorded bindings yet.
20 /// Callers (the GUI / hook) layer their own defaults on top.
21 #[must_use]
22 pub fn effective_bindings(
23 &self,
24 device_key: &str,
25 bundle_id: Option<&str>,
26 ) -> BTreeMap<ButtonId, Binding> {
27 let Some(device) = self.devices.get(device_key) else {
28 return BTreeMap::new();
29 };
30 let mut out = device.bindings.clone();
31 if let Some(bid) = bundle_id
32 && let Some(overlay) = app_overlay(&device.per_app_bindings, bid)
33 {
34 for (k, v) in overlay {
35 out.insert(*k, Binding::Single(v.clone()));
36 }
37 }
38 out
39 }
40
41 /// Records a per-app override. Creates the device + app entries as
42 /// needed; passing an action of `None` removes the override and prunes
43 /// the empty app map.
44 pub fn set_per_app_binding(
45 &mut self,
46 device_key: &str,
47 bundle_id: &str,
48 button: ButtonId,
49 action: Option<Action>,
50 ) {
51 let entry = self
52 .devices
53 .entry(device_key.to_string())
54 .or_default()
55 .per_app_bindings
56 .entry(bundle_id.to_string())
57 .or_default();
58 match action {
59 Some(a) => {
60 entry.insert(button, a);
61 }
62 None => {
63 entry.remove(&button);
64 }
65 }
66 if let Some(d) = self.devices.get_mut(device_key) {
67 d.per_app_bindings.retain(|_, m| !m.is_empty());
68 }
69 }
70
71 /// The overrides `device_key` stores for the application key `app`,
72 /// or `None` when it has no profile for it.
73 ///
74 /// Exact key, deliberately: this answers "what did the user author under
75 /// this key", which is what an editor needs to show and to clear. The
76 /// question [`Self::has_app_override`] answers — "will the app in front hit
77 /// a profile" — is the matcher's, and goes through the same `exe:` fallback
78 /// the matcher does. The two look interchangeable and are not.
79 #[must_use]
80 pub fn per_app_overrides(
81 &self,
82 device_key: &str,
83 app: &str,
84 ) -> Option<&BTreeMap<ButtonId, Action>> {
85 self.devices
86 .get(device_key)?
87 .per_app_bindings
88 .get(app)
89 .filter(|overrides| !overrides.is_empty())
90 }
91
92 /// Every application key `device_key` has a profile for, in key order.
93 pub fn app_profiles(&self, device_key: &str) -> impl Iterator<Item = &str> {
94 self.devices
95 .get(device_key)
96 .into_iter()
97 .flat_map(|device| device.per_app_bindings.keys().map(String::as_str))
98 }
99
100 /// Drop `device_key`'s whole profile for `app`. Nothing happens when there
101 /// is none.
102 pub fn remove_app_profile(&mut self, device_key: &str, app: &str) {
103 if let Some(device) = self.devices.get_mut(device_key) {
104 device.per_app_bindings.remove(app);
105 }
106 }
107
108 /// Whether `device_key` has a non-empty per-app binding overlay for the
109 /// foreground app `app` (bundle id). Drives the menu-bar popover's "override
110 /// active" badge — when the current app has its own bindings for this
111 /// device, the global bindings are (partly) overridden.
112 #[must_use]
113 pub fn has_app_override(&self, device_key: &str, app: &str) -> bool {
114 self.devices.get(device_key).is_some_and(|d| {
115 app_overlay(&d.per_app_bindings, app).is_some_and(|overlay| !overlay.is_empty())
116 })
117 }
118}
119
120/// Resolve the most specific application overlay for a foreground identifier.
121///
122/// Exact keys retain precedence. On Windows the foreground identifier is a
123/// lower-cased executable path, so `exe:<filename>` provides a stable fallback
124/// for Store and self-updating applications whose install directory changes
125/// between versions. Recognizing both path separators keeps hand-authored
126/// Windows config inspectable on every platform without changing macOS bundle
127/// identifiers or Linux application classes.
128fn app_overlay<'a, T>(overlays: &'a BTreeMap<String, T>, app: &str) -> Option<&'a T> {
129 overlays.get(app).or_else(|| {
130 let executable_name = app.rsplit(['\\', '/']).next()?;
131 if executable_name.is_empty()
132 || !Path::new(executable_name)
133 .extension()
134 .is_some_and(|ext| ext.eq_ignore_ascii_case("exe"))
135 {
136 return None;
137 }
138
139 overlays.get(&format!("exe:{}", executable_name.to_ascii_lowercase()))
140 })
141}