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 goes through [`overlay_for`], which
6//! also falls back from a Windows executable path to that executable's
7//! `exe:<filename>` key.
8
9use std::collections::BTreeMap;
10
11use super::Config;
12use crate::app::overlay_for;
13use crate::binding::{Action, Binding, ButtonId};
14
15impl Config {
16    /// Resolve the effective binding map for `device_key`, overlaying the
17    /// per-app entry for `bundle_id` (if any) on top of the global per-device
18    /// `bindings`. A per-app override replaces the whole button with a
19    /// [`Binding::Single`]; everything else falls through.
20    ///
21    /// Returns an empty map when the device has no recorded bindings yet.
22    /// Callers (the GUI / hook) layer their own defaults on top.
23    #[must_use]
24    pub fn effective_bindings(
25        &self,
26        device_key: &str,
27        bundle_id: Option<&str>,
28    ) -> BTreeMap<ButtonId, Binding> {
29        let Some(device) = self.devices.get(device_key) else {
30            return BTreeMap::new();
31        };
32        let mut out = device.bindings.clone();
33        if let Some(bid) = bundle_id
34            && let Some(overlay) = overlay_for(&device.per_app_bindings, bid)
35        {
36            for (k, v) in overlay {
37                out.insert(*k, Binding::Single(v.clone()));
38            }
39        }
40        out
41    }
42
43    /// Records a per-app override. Creates the device + app entries as
44    /// needed; passing an action of `None` removes the override and prunes
45    /// the empty app map.
46    pub fn set_per_app_binding(
47        &mut self,
48        device_key: &str,
49        bundle_id: &str,
50        button: ButtonId,
51        action: Option<Action>,
52    ) {
53        let entry = self
54            .devices
55            .entry(device_key.to_string())
56            .or_default()
57            .per_app_bindings
58            .entry(bundle_id.to_string())
59            .or_default();
60        match action {
61            Some(a) => {
62                entry.insert(button, a);
63            }
64            None => {
65                entry.remove(&button);
66            }
67        }
68        if let Some(d) = self.devices.get_mut(device_key) {
69            d.per_app_bindings.retain(|_, m| !m.is_empty());
70        }
71    }
72
73    /// The overrides `device_key` stores for the application key `app`,
74    /// or `None` when it has no profile for it.
75    ///
76    /// Exact key, deliberately: this answers "what did the user author under
77    /// this key", which is what an editor needs to show and to clear. The
78    /// question [`Self::has_app_override`] answers — "will the app in front hit
79    /// a profile" — is the matcher's, and goes through the same `exe:` fallback
80    /// the matcher does. The two look interchangeable and are not.
81    #[must_use]
82    pub fn per_app_overrides(
83        &self,
84        device_key: &str,
85        app: &str,
86    ) -> Option<&BTreeMap<ButtonId, Action>> {
87        self.devices
88            .get(device_key)?
89            .per_app_bindings
90            .get(app)
91            .filter(|overrides| !overrides.is_empty())
92    }
93
94    /// Every application key `device_key` has a profile for, in key order.
95    pub fn app_profiles(&self, device_key: &str) -> impl Iterator<Item = &str> {
96        self.devices
97            .get(device_key)
98            .into_iter()
99            .flat_map(|device| device.per_app_bindings.keys().map(String::as_str))
100    }
101
102    /// Drop `device_key`'s whole profile for `app`. Nothing happens when there
103    /// is none.
104    pub fn remove_app_profile(&mut self, device_key: &str, app: &str) {
105        if let Some(device) = self.devices.get_mut(device_key) {
106            device.per_app_bindings.remove(app);
107        }
108    }
109
110    /// Whether `device_key` has a non-empty per-app binding overlay for the
111    /// foreground app `app`. Drives the menu-bar popover's "override
112    /// active" badge — when the current app has its own bindings for this
113    /// device, the global bindings are (partly) overridden.
114    #[must_use]
115    pub fn has_app_override(&self, device_key: &str, app: &str) -> bool {
116        self.devices.get(device_key).is_some_and(|d| {
117            overlay_for(&d.per_app_bindings, app).is_some_and(|overlay| !overlay.is_empty())
118        })
119    }
120}