Skip to main content

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}