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