Skip to main content

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 the HID++ identifier returned by
6//! [`DeviceModelInfo::config_key`](crate::device::DeviceModelInfo::config_key)
7//! — e.g. `"2b042"` for an MX Master 4. Schema migrations branch on
8//! [`Config::schema_version`].
9
10use std::{
11    collections::BTreeMap,
12    fs, io,
13    path::{Path, PathBuf},
14};
15
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19use crate::binding::{Action, Binding, ButtonId, GestureDirection, default_binding_for};
20use crate::paths::{self, PathsError};
21
22/// The schema version the current build produces. Bumped on breaking layout
23/// changes; readers branch on the parsed value before consuming the rest of
24/// the file.
25///
26/// v2 merged the per-device `button_bindings` + `gesture_bindings` maps into a
27/// single `bindings: BTreeMap<ButtonId, Binding>`. A v1 file still loads (the
28/// [`RawDeviceConfig`] shim folds the legacy fields) and self-heals to v2 on the
29/// next save; [`Config::load_from_path`] rejects only versions *newer* than this
30/// so a forward file fails loudly instead of silently losing bindings.
31pub const SCHEMA_VERSION: u32 = 2;
32
33/// Top-level config document.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Config {
36    pub schema_version: u32,
37    /// Non-device-scoped preferences (autostart, tray, language, …).
38    #[serde(default, skip_serializing_if = "AppSettings::is_default")]
39    pub app_settings: AppSettings,
40    /// HID++ `config_key` of the carousel-selected device, persisted so a
41    /// restart restores the last view rather than always landing on the
42    /// first paired device. `None` means "fall back to the first device".
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub selected_device: Option<String>,
45    #[serde(default)]
46    pub devices: BTreeMap<String, DeviceConfig>,
47}
48
49impl Default for Config {
50    fn default() -> Self {
51        Self {
52            schema_version: SCHEMA_VERSION,
53            app_settings: AppSettings::default(),
54            selected_device: None,
55            devices: BTreeMap::new(),
56        }
57    }
58}
59
60/// App-wide preferences not tied to any particular device.
61///
62/// All fields are `#[serde(default)]` so adding a new one is backward
63/// compatible — old config files just keep the default for the new field.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[allow(
66    clippy::struct_excessive_bools,
67    reason = "independent on/off user preferences, not a state machine"
68)]
69pub struct AppSettings {
70    /// When true, a macOS `LaunchAgent` plist at
71    /// `~/Library/LaunchAgents/org.openlogi.openlogi.plist` is installed
72    /// so the app starts on login (P2.2). The plist is reconciled with
73    /// this field on every startup; flipping the flag and relaunching is
74    /// enough to install / remove it.
75    #[serde(default)]
76    pub launch_at_login: bool,
77    /// Opt-in update check (P2.8). **Off by default** to honour the
78    /// README's "no telemetry, no auto-update poller" promise. When true,
79    /// the app makes exactly one `HEAD /repos/AprilNEA/OpenLogi/releases/
80    /// latest` request per launch and logs whether a newer version is
81    /// available — no automatic download.
82    #[serde(default)]
83    pub check_for_updates: bool,
84    /// True once the first-run "check for updates?" prompt has been answered
85    /// (either way), so it is never shown again. The prompt is how a
86    /// privacy-conscious default of `check_for_updates = false` still lets a
87    /// user opt in on first launch.
88    #[serde(default)]
89    pub update_prompt_seen: bool,
90    /// Whether OpenLogi shows a macOS menu-bar (status item) icon. `true`
91    /// (default) → it lives in the menu bar, dropping the Dock icon while no
92    /// window is open; `false` → it stays an ordinary Dock app with no status
93    /// item. macOS-only; ignored on other platforms.
94    #[serde(default = "default_true")]
95    pub show_in_menu_bar: bool,
96    /// UI language as a BCP-47-ish locale code matching the GUI's bundled
97    /// locales (e.g. `"en"`, `"de"`, `"pt-BR"`, `"zh-CN"`, `"zh-TW"`; see the
98    /// GUI's `i18n::SUPPORTED`). `None` means "follow the system locale", which
99    /// the GUI resolves at startup. Stored here so a user's explicit choice
100    /// survives restarts regardless of the OS setting.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub language: Option<String>,
103    /// Thumb-wheel responsiveness, on a [`MIN_THUMBWHEEL_SENSITIVITY`]–
104    /// [`MAX_THUMBWHEEL_SENSITIVITY`] scale. It scales both the speed of the
105    /// wheel's continuous horizontal scroll and how few rotation increments a
106    /// custom wheel action needs to fire. [`DEFAULT_THUMBWHEEL_SENSITIVITY`]
107    /// (the out-of-the-box value) means 1× scroll speed; the wheel is only
108    /// diverted from native scrolling once this leaves the default.
109    #[serde(default = "default_thumbwheel_sensitivity")]
110    pub thumbwheel_sensitivity: i32,
111}
112
113/// Out-of-the-box [`AppSettings::thumbwheel_sensitivity`]. At this value the
114/// wheel's horizontal scroll runs at 1× and the wheel is left to scroll
115/// natively (no HID++ diversion) unless a binding diverges from its default.
116pub const DEFAULT_THUMBWHEEL_SENSITIVITY: i32 = 14;
117/// Lowest selectable [`AppSettings::thumbwheel_sensitivity`].
118pub const MIN_THUMBWHEEL_SENSITIVITY: i32 = 1;
119/// Highest selectable [`AppSettings::thumbwheel_sensitivity`].
120pub const MAX_THUMBWHEEL_SENSITIVITY: i32 = 100;
121
122impl AppSettings {
123    /// `skip_serializing_if` helper: true when nothing diverges from the
124    /// default, so empty settings don't clutter `config.toml`.
125    #[must_use]
126    pub fn is_default(&self) -> bool {
127        self == &Self::default()
128    }
129}
130
131impl Default for AppSettings {
132    fn default() -> Self {
133        Self {
134            launch_at_login: false,
135            check_for_updates: false,
136            update_prompt_seen: false,
137            show_in_menu_bar: true,
138            language: None,
139            thumbwheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY,
140        }
141    }
142}
143
144/// serde default for [`AppSettings::show_in_menu_bar`]: `true`, so the menu-bar
145/// icon is on out of the box and configs predating the field keep that behavior.
146fn default_true() -> bool {
147    true
148}
149
150/// serde default for [`AppSettings::thumbwheel_sensitivity`]: keeps configs
151/// predating the field at the 1× default.
152const fn default_thumbwheel_sensitivity() -> i32 {
153    DEFAULT_THUMBWHEEL_SENSITIVITY
154}
155
156/// Per-device RGB lighting: a single static color, brightness, and on/off.
157/// Deliberately basic — per-key effects are a later addition.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct Lighting {
160    #[serde(default = "default_lighting_enabled")]
161    pub enabled: bool,
162    /// Static color as 6 hex digits `"RRGGBB"` (no leading `#`).
163    #[serde(default = "default_lighting_color")]
164    pub color: String,
165    /// Brightness percent, clamped to 0–100 on load.
166    #[serde(
167        default = "default_lighting_brightness",
168        deserialize_with = "deserialize_brightness"
169    )]
170    pub brightness: u8,
171}
172
173impl Default for Lighting {
174    fn default() -> Self {
175        Self {
176            enabled: default_lighting_enabled(),
177            color: default_lighting_color(),
178            brightness: default_lighting_brightness(),
179        }
180    }
181}
182
183fn default_lighting_enabled() -> bool {
184    true
185}
186
187fn default_lighting_color() -> String {
188    "ffffff".to_string()
189}
190
191fn default_lighting_brightness() -> u8 {
192    100
193}
194
195/// Clamp a deserialized brightness into the UI's `0..=100` range, so a
196/// hand-edited `config.toml` can't feed out-of-range values into the scaling
197/// math (which assumes `brightness <= 100`).
198fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
199where
200    D: serde::Deserializer<'de>,
201{
202    Ok(u8::deserialize(deserializer)?.min(100))
203}
204
205/// Which control owns a device's single gesture role.
206///
207/// Stored explicitly — rather than inferred from which button happens to carry a
208/// [`Binding::Gesture`] — so switching the gesture button never has to collapse
209/// a button's gesture map to encode the choice: every gesture-capable button
210/// keeps its full direction map, and only the owner is dispatched. Serialized as
211/// a bare string (`"Off"` or a [`ButtonId`] name) so it stays a TOML scalar.
212#[derive(Clone, Copy, Debug, PartialEq, Eq)]
213pub enum GestureOwner {
214    /// Gestures are explicitly turned off for this device.
215    Off,
216    /// The named button owns the gesture role.
217    Button(ButtonId),
218}
219
220impl Serialize for GestureOwner {
221    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
222        match self {
223            // "Off" can't collide with a ButtonId variant name (all CamelCase
224            // control names), so the string space is unambiguous.
225            GestureOwner::Off => serializer.serialize_str("Off"),
226            GestureOwner::Button(id) => id.serialize(serializer),
227        }
228    }
229}
230
231/// Lenient field deserializer for [`RawDeviceConfig::gesture_owner`]. An
232/// unrecognized or miscased value (`"back"`, a typo, a future-version button
233/// name) is treated as absent — i.e. "infer the owner" — rather than failing the
234/// whole-document parse and reverting *every* device's settings to defaults.
235/// Mirrors [`deserialize_brightness`], which clamps a bad value instead of
236/// erroring; a hand-editable config should degrade one field, not the document.
237fn deserialize_gesture_owner<'de, D>(deserializer: D) -> Result<Option<GestureOwner>, D::Error>
238where
239    D: serde::Deserializer<'de>,
240{
241    let s = String::deserialize(deserializer)?;
242    if s == "Off" {
243        return Ok(Some(GestureOwner::Off));
244    }
245    // Parse the button name with a throwaway error type so an unknown token maps
246    // to `None` (infer) rather than propagating an error.
247    let button = ButtonId::deserialize(
248        serde::de::value::StrDeserializer::<serde::de::value::Error>::new(&s),
249    )
250    .ok();
251    Ok(button.map(GestureOwner::Button))
252}
253
254/// Settings scoped to a single physical device (keyed by HID++ model+ext).
255///
256/// Deserialization goes through [`RawDeviceConfig`] (`#[serde(from)]`) so
257/// pre-v2 files — which split bindings across `button_bindings` +
258/// `gesture_bindings` — fold into the unified [`Self::bindings`] map. Only
259/// `bindings` is ever serialized, so a migrated file self-heals to the v2 shape
260/// on its next save.
261#[derive(Debug, Clone, Default, Serialize, Deserialize)]
262#[serde(from = "RawDeviceConfig")]
263pub struct DeviceConfig {
264    /// Which button owns the device's single gesture role, once the user has
265    /// chosen explicitly. Absent means "infer" (the thumb pad owns gestures if
266    /// present) — see [`Config::gesture_owner`]. Listed first so it serializes
267    /// as a scalar ahead of the `bindings` sub-table.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub gesture_owner: Option<GestureOwner>,
270    /// Every rebindable button's binding: a single [`Action`], or — for the
271    /// gesture button (and, later, any raw-XY-capable button) — a
272    /// [`Binding::Gesture`] per-direction map.
273    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
274    pub bindings: BTreeMap<ButtonId, Binding>,
275    /// Per-application binding overlays (P1.4). Keyed by bundle identifier
276    /// (e.g. `"com.microsoft.VSCode"` on macOS). When the foreground app's
277    /// id matches a key here, those bindings take precedence; anything not
278    /// listed falls through to `bindings`. Deliberately `Action`-valued (not
279    /// `Binding`): a per-app override replaces the whole button with one
280    /// action, never a per-direction gesture overlay.
281    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
282    pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
283    /// Ordered list of DPI presets cycled through by
284    /// [`Action::CycleDpiPresets`] and indexed by
285    /// [`Action::SetDpiPreset`]. Empty means "no presets configured" —
286    /// the cycle action becomes a no-op until the user adds at least one.
287    #[serde(default, skip_serializing_if = "Vec::is_empty")]
288    pub dpi_presets: Vec<u32>,
289    /// Per-device RGB lighting (static color + brightness + on/off). `None`
290    /// until the user changes it, so it stays out of `config.toml` otherwise.
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub lighting: Option<Lighting>,
293}
294
295/// Deserialize-only shim that folds the pre-v2 `button_bindings` +
296/// `gesture_bindings` fields into [`DeviceConfig::bindings`]. Never serialized
297/// (only [`DeviceConfig`] is), so reading a legacy file and saving rewrites it
298/// in the v2 shape.
299#[derive(Deserialize)]
300struct RawDeviceConfig {
301    /// Explicit gesture owner (v2.1+). Absent on older configs → `None` → the
302    /// owner is inferred in [`Config::gesture_owner`]. A present-but-invalid
303    /// value is tolerated as `None` (infer), not a parse error — see
304    /// [`deserialize_gesture_owner`].
305    #[serde(default, deserialize_with = "deserialize_gesture_owner")]
306    gesture_owner: Option<GestureOwner>,
307    /// v2 shape — present on already-migrated files; wins on any key collision.
308    #[serde(default)]
309    bindings: BTreeMap<ButtonId, Binding>,
310    /// Legacy v1 per-button single bindings.
311    #[serde(default)]
312    button_bindings: BTreeMap<ButtonId, Action>,
313    /// Legacy v1 flat gesture map (implicitly the gesture button's directions).
314    #[serde(default)]
315    gesture_bindings: BTreeMap<GestureDirection, Action>,
316    #[serde(default)]
317    per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
318    #[serde(default)]
319    dpi_presets: Vec<u32>,
320    #[serde(default)]
321    lighting: Option<Lighting>,
322}
323
324impl From<RawDeviceConfig> for DeviceConfig {
325    fn from(raw: RawDeviceConfig) -> Self {
326        let mut bindings = raw.bindings; // the v2 map wins on every key.
327
328        // Re-home the legacy flat gesture map under `GestureButton`. This MUST
329        // happen before folding `button_bindings`, so a legacy single
330        // `button_bindings[GestureButton]` entry coexisting with a
331        // `gesture_bindings` map cannot claim the slot first and silently drop
332        // the whole direction map (the pre-v2 rule was "gesture entries win").
333        if !raw.gesture_bindings.is_empty() {
334            bindings
335                .entry(ButtonId::GestureButton)
336                .or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
337        }
338        for (button, action) in raw.button_bindings {
339            // A legacy `button_bindings[GestureButton]` is vestigial and must not
340            // become a `Binding::Single`: the gesture button never dispatched
341            // through the per-button map (it is not an OS-hook button, and its
342            // plain press routes through the gesture `Click` slot — see
343            // agent-core `bindings_for`). A `Single` here would be unreachable —
344            // the GUI hides it and the runtime ignores it — while folding it into
345            // `Click` would resurrect a dead binding as a behavior change. Drop
346            // it: the gesture map (re-homed above) already owns this button, and
347            // an absent entry falls back to the canonical default, exactly as
348            // pre-v2.
349            if button == ButtonId::GestureButton {
350                continue;
351            }
352            bindings.entry(button).or_insert(Binding::Single(action));
353        }
354
355        DeviceConfig {
356            gesture_owner: raw.gesture_owner,
357            bindings,
358            per_app_bindings: raw.per_app_bindings,
359            dpi_presets: raw.dpi_presets,
360            lighting: raw.lighting,
361        }
362    }
363}
364
365#[derive(Debug, Error)]
366pub enum ConfigError {
367    #[error("could not resolve config path")]
368    Path(#[from] PathsError),
369    #[error("could not read config at {path}")]
370    Read {
371        path: PathBuf,
372        #[source]
373        source: io::Error,
374    },
375    #[error("could not parse config at {path}")]
376    Parse {
377        path: PathBuf,
378        #[source]
379        source: toml::de::Error,
380    },
381    #[error("could not write config at {path}")]
382    Write {
383        path: PathBuf,
384        #[source]
385        source: io::Error,
386    },
387    #[error("could not serialize config")]
388    Serialize(#[from] toml::ser::Error),
389    #[error("config at {path} has unsupported schema_version {found}")]
390    UnsupportedSchemaVersion { path: PathBuf, found: u32 },
391}
392
393#[allow(
394    clippy::result_large_err,
395    reason = "Config I/O keeps rich parse/write context and is not a hot path"
396)]
397impl Config {
398    /// Loads the config from the default user path, returning
399    /// [`Config::default`] if the file does not exist yet.
400    pub fn load_or_default() -> Result<Self, ConfigError> {
401        Self::load_from_path(&paths::config_path()?)
402    }
403
404    /// Same as [`Self::load_or_default`] but reads from `path`. Used by tests
405    /// to avoid touching the real user config.
406    pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
407        match fs::read_to_string(path) {
408            Ok(text) => {
409                let mut config: Self =
410                    toml::from_str(&text).map_err(|source| ConfigError::Parse {
411                        path: path.to_path_buf(),
412                        source,
413                    })?;
414                // Accept any version up to the current one: older files migrate
415                // through the per-device [`RawDeviceConfig`] shim and self-heal on
416                // the next save. Only a *newer* file is rejected — loudly, so a
417                // downgraded binary refuses to load (and silently wipe) a config
418                // it can't represent.
419                if config.schema_version > SCHEMA_VERSION {
420                    return Err(ConfigError::UnsupportedSchemaVersion {
421                        path: path.to_path_buf(),
422                        found: config.schema_version,
423                    });
424                }
425                // Stamp the in-memory doc to the current version so a re-save
426                // writes the migrated v2 shape (the device shim already folded
427                // the legacy fields during deserialize).
428                config.schema_version = SCHEMA_VERSION;
429                Ok(config)
430            }
431            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
432            Err(source) => Err(ConfigError::Read {
433                path: path.to_path_buf(),
434                source,
435            }),
436        }
437    }
438
439    /// Writes the config atomically to the default user path: serialize to a
440    /// sibling temp file, then rename over the target. On Unix the temp file
441    /// is created with mode 0600.
442    pub fn save_atomic(&self) -> Result<(), ConfigError> {
443        self.save_to_path(&paths::config_path()?)
444    }
445
446    /// Same as [`Self::save_atomic`] but writes to `path`. Used by tests.
447    pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
448        if let Some(parent) = path.parent() {
449            fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
450                path: path.to_path_buf(),
451                source,
452            })?;
453        }
454        let body = toml::to_string_pretty(self)?;
455        write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
456            path: path.to_path_buf(),
457            source,
458        })
459    }
460
461    /// Returns the bindings stored for `device_key`, or an empty map if the
462    /// device has no committed bindings yet.
463    #[must_use]
464    pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
465        self.devices
466            .get(device_key)
467            .map(|d| d.bindings.clone())
468            .unwrap_or_default()
469    }
470
471    /// Records `binding` for `button` on `device_key`, creating the device
472    /// entry if needed. Replaces the whole binding (use
473    /// [`Self::set_gesture_direction`] to edit one direction of a gesture
474    /// binding in place).
475    pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
476        self.devices
477            .entry(device_key.to_string())
478            .or_default()
479            .bindings
480            .insert(button, binding);
481    }
482
483    /// Returns the gesture sub-bindings for `device_key`'s gesture button, or an
484    /// empty map if it isn't in gesture mode. Derived from the unified
485    /// [`DeviceConfig::bindings`]; kept as a convenience for the agent-side
486    /// per-direction adapter.
487    #[must_use]
488    pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
489        match self
490            .devices
491            .get(device_key)
492            .and_then(|d| d.bindings.get(&ButtonId::GestureButton))
493        {
494            Some(Binding::Gesture(map)) => map.clone(),
495            _ => BTreeMap::new(),
496        }
497    }
498
499    /// Records `action` for one `direction` of `button`'s gesture binding,
500    /// creating the device entry if needed.
501    ///
502    /// A button with no binding yet is seeded from its canonical
503    /// [`default_binding_for`] — for [`ButtonId::GestureButton`] that is the full
504    /// default direction map (including a [`GestureDirection::Click`]), so the
505    /// merged map never persists a gesture binding whose click projection is a
506    /// no-op. A prior [`Binding::Single`] is upgraded to [`Binding::Gesture`],
507    /// preserving its action as the `Click` entry.
508    pub fn set_gesture_direction(
509        &mut self,
510        device_key: &str,
511        button: ButtonId,
512        direction: GestureDirection,
513        action: Action,
514    ) {
515        if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
516            map.insert(direction, action);
517        }
518    }
519
520    /// Ensure `button` on `device_key` is a [`Binding::Gesture`], creating the
521    /// device + a default binding if needed and upgrading a [`Binding::Single`]
522    /// in place (its action kept as the [`GestureDirection::Click`]). Returns the
523    /// entry so the caller can finish it — seed every direction
524    /// ([`Binding::fill_gesture_defaults`]) or set just one. Shared by
525    /// [`Self::set_gesture_owner`] and [`Self::set_gesture_direction`] so the two
526    /// promote a button into gesture mode identically.
527    fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
528        let entry = self
529            .devices
530            .entry(device_key.to_string())
531            .or_default()
532            .bindings
533            .entry(button)
534            .or_insert_with(|| default_binding_for(button));
535        entry.upgrade_to_gesture();
536        entry
537    }
538
539    /// The button that owns `device_key`'s single gesture role, or `None` when
540    /// gestures are turned off.
541    ///
542    /// Resolved from the explicit [`DeviceConfig::gesture_owner`] when present;
543    /// otherwise inferred (see [`Self::infer_gesture_owner`]) for configs
544    /// predating the field and freshly-migrated pre-v2 files. The dedicated thumb
545    /// pad ([`ButtonId::GestureButton`]) owns the role by default. At most one
546    /// button gestures per device.
547    #[must_use]
548    pub fn gesture_owner(&self, device_key: &str) -> Option<ButtonId> {
549        let Some(device) = self.devices.get(device_key) else {
550            // No config yet → the thumb pad is the default gesture owner.
551            return Some(ButtonId::GestureButton);
552        };
553        match device.gesture_owner {
554            Some(GestureOwner::Off) => None,
555            Some(GestureOwner::Button(id)) => Some(id),
556            None => Self::infer_gesture_owner(&device.bindings),
557        }
558    }
559
560    /// Infer the gesture owner for a config predating the explicit
561    /// [`DeviceConfig::gesture_owner`] field, from the shape of `bindings` — the
562    /// pre-field behavior, so old/migrated configs keep working until the first
563    /// explicit owner change stamps the field.
564    fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
565        // An OS-hook button left in gesture mode took the role over.
566        if let Some((id, _)) = bindings
567            .iter()
568            .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
569        {
570            return Some(*id);
571        }
572        // A thumb pad explicitly demoted to a single action means gestures off.
573        if matches!(
574            bindings.get(&ButtonId::GestureButton),
575            Some(Binding::Single(_))
576        ) {
577            return None;
578        }
579        // Default: the thumb pad owns the gesture role.
580        Some(ButtonId::GestureButton)
581    }
582
583    /// Make `button` the device's sole gesture button.
584    ///
585    /// Records `button` as the explicit [`gesture_owner`](Self::gesture_owner), so
586    /// the one-gesture-button-per-device lock is a data-model fact rather than a
587    /// destructive demotion of the others — every other gesture-capable button
588    /// keeps its own gesture map intact, ready to restore if re-chosen, and is
589    /// simply not dispatched while it isn't the owner. `button` is given a full
590    /// [`Binding::Gesture`] map: a prior [`Binding::Single`] is kept as the
591    /// [`GestureDirection::Click`] action, any existing swipe arms are preserved,
592    /// and unbound directions are seeded from [`default_gesture_binding`] so every
593    /// gesture button exposes the same full five-direction set.
594    pub fn set_gesture_owner(&mut self, device_key: &str, button: ButtonId) {
595        self.devices
596            .entry(device_key.to_string())
597            .or_default()
598            .gesture_owner = Some(GestureOwner::Button(button));
599        self.ensure_gesture_binding(device_key, button)
600            .fill_gesture_defaults();
601    }
602
603    /// Turn gestures off for `device_key`, recording the explicit "off" choice.
604    /// Every button keeps its gesture map intact (nothing is destroyed), so
605    /// re-selecting a gesture owner later restores its directions exactly.
606    pub fn disable_gestures(&mut self, device_key: &str) {
607        self.devices
608            .entry(device_key.to_string())
609            .or_default()
610            .gesture_owner = Some(GestureOwner::Off);
611    }
612
613    /// Resolve the effective binding map for `device_key`, overlaying the
614    /// per-app entry for `bundle_id` (if any) on top of the global per-device
615    /// `bindings`. A per-app override replaces the whole button with a
616    /// [`Binding::Single`]; everything else falls through.
617    ///
618    /// Returns an empty map when the device has no recorded bindings yet.
619    /// Callers (the GUI / hook) layer their own defaults on top.
620    #[must_use]
621    pub fn effective_bindings(
622        &self,
623        device_key: &str,
624        bundle_id: Option<&str>,
625    ) -> BTreeMap<ButtonId, Binding> {
626        let Some(device) = self.devices.get(device_key) else {
627            return BTreeMap::new();
628        };
629        let mut out = device.bindings.clone();
630        if let Some(bid) = bundle_id {
631            if let Some(overlay) = device.per_app_bindings.get(bid) {
632                for (k, v) in overlay {
633                    out.insert(*k, Binding::Single(v.clone()));
634                }
635            }
636        }
637        out
638    }
639
640    /// Records a per-app override. Creates the device + app entries as
641    /// needed; passing an action of `None` removes the override and prunes
642    /// the empty app map.
643    pub fn set_per_app_binding(
644        &mut self,
645        device_key: &str,
646        bundle_id: &str,
647        button: ButtonId,
648        action: Option<Action>,
649    ) {
650        let entry = self
651            .devices
652            .entry(device_key.to_string())
653            .or_default()
654            .per_app_bindings
655            .entry(bundle_id.to_string())
656            .or_default();
657        match action {
658            Some(a) => {
659                entry.insert(button, a);
660            }
661            None => {
662                entry.remove(&button);
663            }
664        }
665        if let Some(d) = self.devices.get_mut(device_key) {
666            d.per_app_bindings.retain(|_, m| !m.is_empty());
667        }
668    }
669
670    /// HID++ config key of the carousel-selected device, if any.
671    #[must_use]
672    pub fn selected_device(&self) -> Option<&str> {
673        self.selected_device.as_deref()
674    }
675
676    /// Update the carousel-selected device. Pass `None` to clear the
677    /// selection (e.g. when the previously-selected device disappears).
678    pub fn set_selected_device(&mut self, key: Option<String>) {
679        self.selected_device = key;
680    }
681
682    /// The ordered DPI preset list for `device_key`, or an empty `Vec` if the
683    /// device has none configured yet.
684    #[must_use]
685    pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
686        self.devices
687            .get(device_key)
688            .map(|d| d.dpi_presets.clone())
689            .unwrap_or_default()
690    }
691
692    /// Replace the DPI preset list for `device_key`. Pass an empty `Vec` to
693    /// clear (the device block is kept; the field is just omitted on save
694    /// thanks to `skip_serializing_if`).
695    pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
696        self.devices
697            .entry(device_key.to_string())
698            .or_default()
699            .dpi_presets = presets;
700    }
701
702    /// The lighting config for `device_key`, or `None` if unset.
703    #[must_use]
704    pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
705        self.devices
706            .get(device_key)
707            .and_then(|d| d.lighting.clone())
708    }
709
710    /// Replace the lighting config for `device_key`.
711    pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
712        self.devices
713            .entry(device_key.to_string())
714            .or_default()
715            .lighting = Some(lighting);
716    }
717}
718
719fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
720    let tmp = path.with_extension("toml.tmp");
721    {
722        #[cfg(unix)]
723        {
724            use std::os::unix::fs::OpenOptionsExt;
725            let mut f = fs::OpenOptions::new()
726                .write(true)
727                .create(true)
728                .truncate(true)
729                .mode(0o600)
730                .open(&tmp)?;
731            io::Write::write_all(&mut f, bytes)?;
732            f.sync_all()?;
733        }
734        #[cfg(not(unix))]
735        {
736            let mut f = fs::OpenOptions::new()
737                .write(true)
738                .create(true)
739                .truncate(true)
740                .open(&tmp)?;
741            io::Write::write_all(&mut f, bytes)?;
742            f.sync_all()?;
743        }
744    }
745    fs::rename(&tmp, path)
746}
747
748#[cfg(test)]
749#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
750mod tests {
751    use super::*;
752    use crate::binding::{default_binding, default_gesture_binding};
753
754    fn write_and_read(config: &Config) -> Config {
755        let dir = tempfile::tempdir().expect("tempdir");
756        let path = dir.path().join("config.toml");
757        config.save_to_path(&path).expect("save");
758        Config::load_from_path(&path).expect("load")
759    }
760
761    #[test]
762    fn missing_file_yields_default() {
763        let dir = tempfile::tempdir().expect("tempdir");
764        let path = dir.path().join("nonexistent.toml");
765        let cfg = Config::load_from_path(&path).expect("load");
766        assert_eq!(cfg.schema_version, SCHEMA_VERSION);
767        assert!(cfg.devices.is_empty());
768    }
769
770    #[test]
771    fn lighting_roundtrips_per_device() {
772        let mut cfg = Config::default();
773        cfg.set_lighting(
774            "g513",
775            Lighting {
776                enabled: true,
777                color: "00aabb".to_string(),
778                brightness: 75,
779            },
780        );
781        let restored = write_and_read(&cfg);
782        assert_eq!(
783            restored.lighting("g513"),
784            Some(Lighting {
785                enabled: true,
786                color: "00aabb".to_string(),
787                brightness: 75,
788            })
789        );
790        assert_eq!(restored.lighting("absent"), None);
791    }
792
793    #[test]
794    fn bindings_roundtrip_per_device() {
795        let mut cfg = Config::default();
796        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
797        cfg.set_binding(
798            "2b042",
799            ButtonId::DpiToggle,
800            Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
801                modifiers: crate::binding::KeyCombo::MOD_CMD,
802                key_code: 0x23, // kVK_ANSI_P
803                display: "⌘P".into(),
804            })),
805        );
806        cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
807
808        let parsed = write_and_read(&cfg);
809
810        // Per-device isolation.
811        let a = parsed.bindings_for("2b042");
812        assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
813        assert_eq!(
814            a.get(&ButtonId::DpiToggle),
815            Some(&Binding::Single(Action::CustomShortcut(
816                crate::binding::KeyCombo {
817                    modifiers: crate::binding::KeyCombo::MOD_CMD,
818                    key_code: 0x23,
819                    display: "⌘P".into(),
820                }
821            )))
822        );
823
824        let b = parsed.bindings_for("4082d");
825        assert_eq!(
826            b.get(&ButtonId::Back),
827            Some(&Binding::Single(Action::Paste))
828        );
829        assert_eq!(b.len(), 1, "device b should only see its own bindings");
830
831        // Unknown device returns empty map without panic.
832        assert!(parsed.bindings_for("deadbeef").is_empty());
833    }
834
835    #[test]
836    fn human_readable_toml_layout() {
837        let mut cfg = Config::default();
838        cfg.set_binding(
839            "2b042",
840            ButtonId::Back,
841            Binding::Single(Action::BrowserBack),
842        );
843        let body = toml::to_string_pretty(&cfg).expect("serialize");
844
845        // The model id only contains [A-Za-z0-9_], so TOML emits it as a
846        // bare-word table key (no surrounding quotes). The test asserts the
847        // observable structure rather than locking in a specific quoting.
848        assert!(body.contains("schema_version = 2"), "got: {body}");
849        assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
850        // A `Single` binding serializes byte-identically to the pre-v2 bare
851        // `Action`, so the leaf line is unchanged.
852        assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
853    }
854
855    #[test]
856    fn dpi_presets_roundtrip_per_device() {
857        let mut cfg = Config::default();
858        cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
859        cfg.set_dpi_presets("4082d", vec![400, 1600]);
860
861        let parsed = write_and_read(&cfg);
862
863        assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
864        assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
865        assert!(parsed.dpi_presets("unknown").is_empty());
866    }
867
868    #[test]
869    fn empty_dpi_presets_skip_serialization() {
870        let mut cfg = Config::default();
871        // Add a binding so the device block exists.
872        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
873        cfg.set_dpi_presets("2b042", vec![800]);
874        cfg.set_dpi_presets("2b042", vec![]); // clear
875
876        let body = toml::to_string_pretty(&cfg).expect("serialize");
877        assert!(
878            !body.contains("dpi_presets"),
879            "empty dpi_presets should be omitted: {body}"
880        );
881    }
882
883    #[test]
884    fn selected_device_roundtrips() {
885        let mut cfg = Config::default();
886        assert_eq!(cfg.selected_device(), None);
887        cfg.set_selected_device(Some("2b042".into()));
888        let parsed = write_and_read(&cfg);
889        assert_eq!(parsed.selected_device(), Some("2b042"));
890    }
891
892    #[test]
893    fn per_app_overlay_takes_precedence() {
894        let mut cfg = Config::default();
895        cfg.set_binding(
896            "2b042",
897            ButtonId::Back,
898            Binding::Single(Action::BrowserBack),
899        );
900        cfg.set_binding(
901            "2b042",
902            ButtonId::Forward,
903            Binding::Single(Action::BrowserForward),
904        );
905        cfg.set_per_app_binding(
906            "2b042",
907            "com.microsoft.VSCode",
908            ButtonId::Back,
909            Some(Action::Undo),
910        );
911
912        // Global: both buttons are browser nav.
913        let global = cfg.effective_bindings("2b042", None);
914        assert_eq!(
915            global.get(&ButtonId::Back),
916            Some(&Binding::Single(Action::BrowserBack))
917        );
918        assert_eq!(
919            global.get(&ButtonId::Forward),
920            Some(&Binding::Single(Action::BrowserForward))
921        );
922
923        // VSCode: Back overridden (wrapped as Single), Forward inherits.
924        let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
925        assert_eq!(
926            vscode.get(&ButtonId::Back),
927            Some(&Binding::Single(Action::Undo))
928        );
929        assert_eq!(
930            vscode.get(&ButtonId::Forward),
931            Some(&Binding::Single(Action::BrowserForward))
932        );
933
934        // Unrelated app falls through.
935        let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
936        assert_eq!(
937            other.get(&ButtonId::Back),
938            Some(&Binding::Single(Action::BrowserBack))
939        );
940    }
941
942    #[test]
943    fn per_app_binding_removal_prunes_empty_app() {
944        let mut cfg = Config::default();
945        cfg.set_per_app_binding(
946            "2b042",
947            "com.example.App",
948            ButtonId::Back,
949            Some(Action::Copy),
950        );
951        cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
952        assert!(
953            cfg.devices["2b042"].per_app_bindings.is_empty(),
954            "removing last override should prune the app entry"
955        );
956    }
957
958    #[test]
959    fn app_settings_default_omits_block() {
960        let cfg = Config::default();
961        let body = toml::to_string_pretty(&cfg).expect("serialize");
962        assert!(
963            !body.contains("app_settings"),
964            "default app_settings should be omitted: {body}"
965        );
966    }
967
968    #[test]
969    fn app_settings_launch_at_login_roundtrips() {
970        let mut cfg = Config::default();
971        cfg.app_settings.launch_at_login = true;
972        let parsed = write_and_read(&cfg);
973        assert!(parsed.app_settings.launch_at_login);
974    }
975
976    #[test]
977    fn cleared_selected_device_omits_field() {
978        let mut cfg = Config::default();
979        cfg.set_selected_device(Some("2b042".into()));
980        cfg.set_selected_device(None);
981        let body = toml::to_string_pretty(&cfg).expect("serialize");
982        assert!(
983            !body.contains("selected_device"),
984            "cleared selection should not appear: {body}"
985        );
986    }
987
988    #[test]
989    fn empty_device_block_is_skipped_in_output() {
990        // Inserting then clearing should not leave a [devices."x"] header
991        // with no bindings under it (skip_serializing_if on bindings).
992        let mut cfg = Config::default();
993        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
994        cfg.devices
995            .get_mut("2b042")
996            .expect("entry")
997            .bindings
998            .clear();
999        let body = toml::to_string_pretty(&cfg).expect("serialize");
1000        assert!(
1001            !body.contains("Back"),
1002            "cleared bindings should not appear: {body}"
1003        );
1004    }
1005
1006    #[test]
1007    fn migrates_v1_button_and_gesture_bindings() {
1008        // A pre-v2 file: split button_bindings + a flat gesture_bindings map.
1009        let v1 = "\
1010schema_version = 1
1011
1012[devices.2b042.button_bindings]
1013Back = \"BrowserBack\"
1014
1015[devices.2b042.gesture_bindings]
1016Up = \"Copy\"
1017Click = \"Paste\"
1018";
1019        let dir = tempfile::tempdir().expect("tempdir");
1020        let path = dir.path().join("config.toml");
1021        fs::write(&path, v1).expect("write");
1022
1023        // v1 still loads (version <= current) and folds into the merged map.
1024        let cfg = Config::load_from_path(&path).expect("load v1");
1025        let bindings = cfg.bindings_for("2b042");
1026        assert_eq!(
1027            bindings.get(&ButtonId::Back),
1028            Some(&Binding::Single(Action::BrowserBack))
1029        );
1030        let mut gesture = BTreeMap::new();
1031        gesture.insert(GestureDirection::Up, Action::Copy);
1032        gesture.insert(GestureDirection::Click, Action::Paste);
1033        assert_eq!(
1034            bindings.get(&ButtonId::GestureButton),
1035            Some(&Binding::Gesture(gesture))
1036        );
1037
1038        // Saving self-heals to the v2 shape: stamped version + merged table,
1039        // legacy field names gone.
1040        let body = toml::to_string_pretty(&cfg).expect("serialize");
1041        assert!(body.contains("schema_version = 2"), "got: {body}");
1042        assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1043        assert!(!body.contains("button_bindings"), "got: {body}");
1044        assert!(!body.contains("gesture_bindings"), "got: {body}");
1045    }
1046
1047    #[test]
1048    fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
1049        // The data-loss guard: when a legacy single button_bindings[GestureButton]
1050        // entry coexists with a gesture_bindings map (reachable via hand-edited
1051        // or very old configs), the gesture map must survive — not be shadowed by
1052        // the single entry. Mirrors the pre-v2 "gesture entries win" rule.
1053        let v1 = "\
1054schema_version = 1
1055
1056[devices.2b042.button_bindings]
1057GestureButton = \"MissionControl\"
1058
1059[devices.2b042.gesture_bindings]
1060Up = \"Copy\"
1061Down = \"Paste\"
1062";
1063        let dir = tempfile::tempdir().expect("tempdir");
1064        let path = dir.path().join("config.toml");
1065        fs::write(&path, v1).expect("write");
1066
1067        let cfg = Config::load_from_path(&path).expect("load v1");
1068        let mut gesture = BTreeMap::new();
1069        gesture.insert(GestureDirection::Up, Action::Copy);
1070        gesture.insert(GestureDirection::Down, Action::Paste);
1071        assert_eq!(
1072            cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
1073            Some(&Binding::Gesture(gesture)),
1074            "gesture map must win over the legacy single GestureButton entry"
1075        );
1076    }
1077
1078    #[test]
1079    fn migration_drops_vestigial_lone_gesture_button_single() {
1080        // A v1 file with only `button_bindings[GestureButton]` and no
1081        // `gesture_bindings` (the pre-gesture-picker shape). That entry never
1082        // dispatched in v1 — the gesture button's plain press routes through the
1083        // gesture `Click` slot, not the per-button map — so migrating it to a
1084        // `Binding::Single` would leave an unreachable entry the GUI hides and the
1085        // runtime ignores. It must be dropped, not shadow the gesture path.
1086        let v1 = "\
1087schema_version = 1
1088
1089[devices.2b042.button_bindings]
1090GestureButton = \"MissionControl\"
1091Back = \"BrowserBack\"
1092";
1093        let dir = tempfile::tempdir().expect("tempdir");
1094        let path = dir.path().join("config.toml");
1095        fs::write(&path, v1).expect("write");
1096
1097        let bindings = Config::load_from_path(&path)
1098            .expect("load v1")
1099            .bindings_for("2b042");
1100        // An ordinary button still migrates to a `Single`...
1101        assert_eq!(
1102            bindings.get(&ButtonId::Back),
1103            Some(&Binding::Single(Action::BrowserBack))
1104        );
1105        // ...but the vestigial gesture-button single is gone, leaving the button
1106        // to fall back to its canonical default rather than an unreachable entry.
1107        assert_eq!(bindings.get(&ButtonId::GestureButton), None);
1108    }
1109
1110    #[test]
1111    fn rejects_newer_schema_version_but_accepts_v1() {
1112        // A future version is rejected loudly; the current and older versions
1113        // load (older ones migrate through the shim).
1114        let dir = tempfile::tempdir().expect("tempdir");
1115        let path = dir.path().join("config.toml");
1116        fs::write(&path, "schema_version = 99\n").expect("write");
1117        assert!(matches!(
1118            Config::load_from_path(&path).expect_err("v99 should fail"),
1119            ConfigError::UnsupportedSchemaVersion { found: 99, .. }
1120        ));
1121
1122        fs::write(&path, "schema_version = 1\n").expect("write");
1123        assert!(
1124            Config::load_from_path(&path).is_ok(),
1125            "v1 should still load"
1126        );
1127    }
1128
1129    #[test]
1130    fn set_gesture_direction_upgrades_single_to_gesture() {
1131        let mut cfg = Config::default();
1132        // Start from a Single binding, then bind a swipe direction.
1133        cfg.set_binding(
1134            "2b042",
1135            ButtonId::Back,
1136            Binding::Single(Action::BrowserBack),
1137        );
1138        cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
1139
1140        match cfg.bindings_for("2b042").get(&ButtonId::Back) {
1141            Some(Binding::Gesture(map)) => {
1142                // The prior single action is preserved as the Click entry.
1143                assert_eq!(
1144                    map.get(&GestureDirection::Click),
1145                    Some(&Action::BrowserBack)
1146                );
1147                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1148            }
1149            other => panic!("expected Gesture after upgrade, got {other:?}"),
1150        }
1151    }
1152
1153    #[test]
1154    fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
1155        // Binding one direction on a never-configured gesture button must still
1156        // persist a `Click`, so the click projection is the canonical default
1157        // rather than `Action::None` (which reads as a no-op press).
1158        let mut cfg = Config::default();
1159        cfg.set_gesture_direction(
1160            "2b042",
1161            ButtonId::GestureButton,
1162            GestureDirection::Up,
1163            Action::Copy,
1164        );
1165
1166        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1167            Some(Binding::Gesture(map)) => {
1168                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1169                assert_eq!(
1170                    map.get(&GestureDirection::Click),
1171                    Some(&crate::binding::default_gesture_binding(
1172                        GestureDirection::Click
1173                    )),
1174                    "a fresh gesture button must seed a Click from its default"
1175                );
1176            }
1177            other => panic!("expected Gesture, got {other:?}"),
1178        }
1179    }
1180
1181    #[test]
1182    fn gesture_owner_defaults_to_thumb_pad_yields_to_oshook_and_can_be_off() {
1183        let mut cfg = Config::default();
1184        // Default: the thumb pad owns the gesture role even with no config.
1185        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1186
1187        // A thumb-pad gesture binding keeps it the owner.
1188        cfg.set_gesture_direction(
1189            "2b042",
1190            ButtonId::GestureButton,
1191            GestureDirection::Up,
1192            Action::MissionControl,
1193        );
1194        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1195
1196        // An explicit OS-hook gesture button takes the role over.
1197        cfg.set_binding(
1198            "2b042",
1199            ButtonId::Forward,
1200            Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
1201        );
1202        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
1203
1204        // Turning gestures off explicitly yields `None` (not the thumb-pad default).
1205        let mut off = Config::default();
1206        off.disable_gestures("2b042");
1207        assert_eq!(off.gesture_owner("2b042"), None);
1208    }
1209
1210    #[test]
1211    fn set_gesture_owner_records_owner_without_destroying_other_maps() {
1212        let mut cfg = Config::default();
1213        // Customize the thumb pad's Up swipe; it is the (inferred) owner.
1214        cfg.set_gesture_direction(
1215            "2b042",
1216            ButtonId::GestureButton,
1217            GestureDirection::Up,
1218            Action::Copy,
1219        );
1220        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1221
1222        // Promote Back: the owner becomes Back explicitly; the thumb pad keeps
1223        // its full gesture map (no destructive demotion).
1224        cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
1225        cfg.set_gesture_owner("2b042", ButtonId::Back);
1226        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
1227
1228        let bindings = cfg.bindings_for("2b042");
1229        // Back is a full five-direction gesture button: its prior single action
1230        // stays as Click, and the swipe arms are seeded from defaults.
1231        match bindings.get(&ButtonId::Back) {
1232            Some(Binding::Gesture(map)) => {
1233                assert_eq!(
1234                    map.get(&GestureDirection::Click),
1235                    Some(&Action::BrowserBack)
1236                );
1237                assert_eq!(
1238                    map.get(&GestureDirection::Up),
1239                    Some(&default_gesture_binding(GestureDirection::Up)),
1240                    "a promoted button gets full default arms"
1241                );
1242            }
1243            other => panic!("expected Back to be a gesture binding, got {other:?}"),
1244        }
1245        // The thumb pad's customized map survived the switch intact.
1246        match bindings.get(&ButtonId::GestureButton) {
1247            Some(Binding::Gesture(map)) => {
1248                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1249            }
1250            other => panic!("expected the thumb pad map preserved, got {other:?}"),
1251        }
1252
1253        // Switching back restores the user's customization, not defaults
1254        // (regression guard: owner-switch used to discard the swipe arms).
1255        cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1256        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1257        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1258            Some(Binding::Gesture(map)) => {
1259                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1260            }
1261            other => panic!("expected preserved gesture map, got {other:?}"),
1262        }
1263    }
1264
1265    #[test]
1266    fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
1267        let mut cfg = Config::default();
1268        // The dedicated thumb pad gets the full default direction map.
1269        cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1270        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1271            Some(Binding::Gesture(map)) => {
1272                for dir in GestureDirection::ALL {
1273                    assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1274                }
1275            }
1276            other => panic!("expected full default gesture map, got {other:?}"),
1277        }
1278
1279        // A fresh OS-hook button also gets all five directions, not just a Click:
1280        // its native action stays as Click, and the swipe arms are defaults — so
1281        // the GUI's shown defaults are exactly what the runtime dispatches.
1282        cfg.set_gesture_owner("2b042", ButtonId::Forward);
1283        match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
1284            Some(Binding::Gesture(map)) => {
1285                assert_eq!(
1286                    map.get(&GestureDirection::Click),
1287                    Some(&default_binding(ButtonId::Forward))
1288                );
1289                for dir in [
1290                    GestureDirection::Up,
1291                    GestureDirection::Down,
1292                    GestureDirection::Left,
1293                    GestureDirection::Right,
1294                ] {
1295                    assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1296                }
1297            }
1298            other => panic!("expected full gesture map for Forward, got {other:?}"),
1299        }
1300    }
1301
1302    #[test]
1303    fn disable_gestures_turns_off_without_destroying_maps() {
1304        let mut cfg = Config::default();
1305        cfg.set_gesture_direction(
1306            "2b042",
1307            ButtonId::GestureButton,
1308            GestureDirection::Up,
1309            Action::Copy,
1310        );
1311        cfg.disable_gestures("2b042");
1312        // Off, but the thumb pad's customized map is preserved (re-enabling
1313        // restores it rather than resurrecting a wiped default).
1314        assert_eq!(cfg.gesture_owner("2b042"), None);
1315        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1316            Some(Binding::Gesture(map)) => {
1317                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1318            }
1319            other => panic!("expected the gesture map preserved while off, got {other:?}"),
1320        }
1321    }
1322
1323    #[test]
1324    fn gesture_owner_field_roundtrips_as_a_scalar() {
1325        let mut cfg = Config::default();
1326        cfg.set_gesture_owner("2b042", ButtonId::Back); // explicit button
1327        cfg.disable_gestures("4082d"); // explicit off
1328
1329        let parsed = write_and_read(&cfg);
1330        assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
1331        assert_eq!(parsed.gesture_owner("4082d"), None);
1332
1333        // The custom codec keeps it a bare TOML string (a nested table would risk
1334        // a value-after-table serialization error, since `bindings` is a table).
1335        let body = toml::to_string_pretty(&cfg).expect("serialize");
1336        assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
1337        assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
1338    }
1339
1340    #[test]
1341    fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
1342        // A hand-edit typo in gesture_owner must NOT fail the whole-document parse
1343        // (which would revert every device's settings to defaults). It degrades
1344        // to "infer" while the rest of the device config survives.
1345        let toml = "\
1346schema_version = 2
1347
1348[devices.2b042]
1349gesture_owner = \"bogus\"
1350
1351[devices.2b042.bindings]
1352Back = \"Copy\"
1353";
1354        let dir = tempfile::tempdir().expect("tempdir");
1355        let path = dir.path().join("config.toml");
1356        fs::write(&path, toml).expect("write");
1357
1358        let cfg =
1359            Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
1360        // The rest of the device config survived...
1361        assert_eq!(
1362            cfg.bindings_for("2b042").get(&ButtonId::Back),
1363            Some(&Binding::Single(Action::Copy))
1364        );
1365        // ...and the bad owner degraded to inference (thumb-pad default here).
1366        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1367    }
1368}