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 device's own identity such as
6//! `"unit:6be9d300"` — or, for a device that reports none, by the route it was
7//! reached on, such as `"receiver:abc123:slot:2"`. Schema migrations branch on
8//! [`Config::schema_version`].
9
10use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13
14mod button_map;
15mod device;
16#[cfg(feature = "fs")]
17mod file;
18mod function_key;
19mod gestures;
20mod identity;
21mod key_trigger;
22mod migrate;
23mod per_app;
24mod per_device;
25mod settings;
26
27// Stacked, not `all(test, …)`: clippy reads the combined form as a test
28// outside a test module and withdraws the `unwrap`/`expect` exemption.
29#[cfg(test)]
30#[cfg(feature = "fs")]
31mod tests;
32
33pub use device::{DeviceConfig, DeviceIdentity, LinkConfig, LinkOverrides};
34#[cfg(all(test, feature = "fs", unix))]
35use file::resolve_symlinks;
36#[cfg(feature = "fs")]
37pub use file::{ConfigError, ConfigFile};
38#[cfg(all(test, feature = "fs"))]
39use file::{backup_existing_config, config_backup_path};
40pub use function_key::FunctionKey;
41pub use identity::canonical_device_key;
42pub use key_trigger::{KeyModifiers, KeyTrigger, KeyboardConfig, ParseTriggerError};
43pub use settings::LightSettings;
44pub use settings::{
45    AppIcon, AppSettings, Appearance, AssetSourcePreference, CameraControls, DeviceViewMode,
46    Lighting, MouseProfileTarget, SMARTSHIFT_AUTO_DISENGAGE_DEFAULT, SMARTSHIFT_MIN_AUTO_DISENGAGE,
47    ScrollResolution, SmartShift, ThumbwheelSensitivity, UiScale, VerticalScrollSensitivity,
48    WheelMode,
49};
50
51use crate::binding::Action;
52#[cfg(all(test, feature = "fs"))]
53use crate::binding::{Binding, ButtonId, GestureDirection};
54/// The schema version the current build produces. Bumped whenever the
55/// persisted shape or enum vocabulary changes; readers inspect this value
56/// before consuming the rest of the file.
57///
58/// v7 aligns the thumb-wheel scroll defaults with its normalised physical
59/// direction. Pre-v7 explicit default pairs are migrated in device and
60/// per-application profiles so they remain native rather than becoming a
61/// reversal (see `Config::migrate_thumbwheel_native_direction`).
62///
63/// v6 adds threshold-based `{ short = ..., long = ... }` button bindings.
64///
65/// v5 also drops the transport prefix from `direct:` keys: `direct:046d:c08d:unit:6be9d300`
66/// names the mouse *and the cable it was plugged into*, so a device moved to a
67/// different route was silently orphaned from its settings.
68/// [`Config::migrate_transport_scoped_keys`] rewrites such a key to its bare
69/// identity fragment (`unit:6be9d300`) — including `selected_device` and every
70/// `host_switch_targets` entry — and keeps the dropped route as a
71/// [`DeviceConfig::links`] entry. `receiver:` keys are left alone: nothing on
72/// disk says which device occupies a pairing slot, so those are folded at
73/// runtime instead, on the next online sighting (see `adopt_route`).
74///
75/// v5 adds the app-wide `ui_scale` preference. Older files default to the
76/// standard 100% scale.
77///
78/// Per-device custom names and the Home gallery view preference are optional
79/// and did not require a version bump: absent fields use the model name and
80/// responsive grid respectively.
81///
82/// v4 removes the one-gesture-button-per-device owner lock: gesture mode is a
83/// per-button fact read from the binding shape, so `gesture_owner` no longer
84/// serializes. Loading a v3-or-older file resolves the old owner and rewrites
85/// the shapes to dispatch identically
86/// (see `Config::migrate_owner_locked_gestures`); the version gate is what
87/// keeps that pass off v4 files, where several gesture-shaped buttons are a
88/// deliberate state, not a dormant leftover.
89///
90/// v3 changes the device map from model keys to physical-device keys. No v2
91/// device entries are migrated because model-scoped settings cannot be assigned
92/// safely when two identical devices exist.
93///
94/// v2 merged the per-device `button_bindings` + `gesture_bindings` maps into a
95/// single `bindings: BTreeMap<ButtonId, Binding>`. A v1 file still loads (the
96/// `RawDeviceConfig` shim folds the legacy fields) and self-heals to v2 on the
97/// next save; [`Config::load_from_path`] accepts supported versions `1` through
98/// [`SCHEMA_VERSION`] so an invalid or forward file fails loudly instead of
99/// silently losing bindings.
100pub const SCHEMA_VERSION: u32 = 7;
101
102/// Top-level config document.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct Config {
106    /// Schema version the file was written with. Compared against
107    /// [`SCHEMA_VERSION`] on load: supported older layouts migrate, while zero
108    /// and newer layouts are rejected rather than silently losing settings.
109    pub schema_version: u32,
110    /// Non-device-scoped preferences (autostart, tray, language, …).
111    #[serde(default, skip_serializing_if = "AppSettings::is_default")]
112    pub app_settings: AppSettings,
113    /// Physical config key of the active device, persisted so a
114    /// restart restores the last view rather than always landing on the
115    /// first paired device. `None` means "fall back to the first device".
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub selected_device: Option<String>,
118    /// When set (see [`Self::ephemeral`]), [`Self::save_atomic`] is a no-op:
119    /// this config never writes the on-disk file. Never true for a loaded or
120    /// default-constructed config.
121    #[serde(skip)]
122    // Read only by the `fs` half, which is where saving happens. The field
123    // stays in every build: `Config::ephemeral()` is public API, and a field
124    // that exists conditionally is a struct whose shape depends on a feature.
125    #[cfg_attr(
126        not(feature = "fs"),
127        expect(clippy::allow_attributes, reason = "see above"),
128        allow(dead_code, reason = "only the `fs` half suppresses a save")
129    )]
130    ephemeral: bool,
131    /// Per-device state, normally keyed by the stable physical-device
132    /// identifier (e.g. `"receiver:abc123:slot:2"`). A serial-less camera's
133    /// custom name instead uses its OS capture id so same-model cameras remain
134    /// distinguishable.
135    #[serde(default)]
136    pub devices: BTreeMap<String, DeviceConfig>,
137    /// Keyboard remappings, independent of device. The function-key remapper
138    /// (M1) reads this; `#[serde(default)]` keeps older configs without a
139    /// `[keyboard]` section loading unchanged.
140    #[serde(default)]
141    pub keyboard: KeyboardConfig,
142}
143
144impl Default for Config {
145    fn default() -> Self {
146        Self {
147            schema_version: SCHEMA_VERSION,
148            app_settings: AppSettings::default(),
149            selected_device: None,
150            devices: BTreeMap::new(),
151            ephemeral: false,
152            keyboard: KeyboardConfig::default(),
153        }
154    }
155}
156
157impl Config {
158    /// A config that never touches the on-disk file: [`Self::save_atomic`] is
159    /// a no-op. For tests that drive the state layer's persistence paths —
160    /// with a default config those would overwrite the developer's real
161    /// `config.toml` with test fixtures.
162    #[must_use]
163    pub fn ephemeral() -> Self {
164        Self {
165            ephemeral: true,
166            ..Self::default()
167        }
168    }
169
170    /// Records (or, with `action = None`, clears) the F-key `trigger` binding
171    /// in the global `[keyboard]` map. Keyboard bindings are device-agnostic —
172    /// one map applies across all keyboards — so this mirrors [`Self::set_binding`]
173    /// minus the device key.
174    pub fn set_keyboard_binding(&mut self, trigger: KeyTrigger, action: Option<Action>) {
175        match action {
176            Some(a) => {
177                self.keyboard.bindings.insert(trigger, a);
178            }
179            None => {
180                self.keyboard.bindings.remove(&trigger);
181            }
182        }
183    }
184
185    /// The global keyboard F-key bindings (read accessor).
186    #[must_use]
187    pub fn keyboard_bindings(&self) -> &BTreeMap<KeyTrigger, Action> {
188        &self.keyboard.bindings
189    }
190
191    /// HID++ config key of the active device, if any.
192    #[must_use]
193    pub fn selected_device(&self) -> Option<&str> {
194        self.selected_device.as_deref()
195    }
196
197    /// Update the active device. Pass `None` to clear the
198    /// selection (e.g. when the previously-selected device disappears).
199    pub fn set_selected_device(&mut self, key: Option<String>) {
200        self.selected_device = key;
201    }
202}