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}