Skip to main content

openlogi_core/config/
settings.rs

1//! App-wide and per-device *value* settings: [`AppSettings`], [`Appearance`],
2//! [`Lighting`], [`WheelMode`] / [`SmartShift`], and [`GestureOwner`], plus
3//! their serde `default_*` / `deserialize_*` helpers.
4
5use serde::{Deserialize, Serialize};
6
7use crate::binding::ButtonId;
8use crate::color::Rgb;
9
10/// Light/dark appearance preference. `System` follows the OS appearance (the
11/// historical behaviour); `Light` / `Dark` force a mode regardless of the OS.
12/// Platform-free so the core crate stays GUI-agnostic — the GUI maps this onto
13/// gpui-component's `ThemeMode`.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum Appearance {
17    /// Follow the operating system's light/dark setting.
18    #[default]
19    System,
20    /// Always use the light variant of the selected theme.
21    Light,
22    /// Always use the dark variant of the selected theme.
23    Dark,
24}
25
26/// App-wide preferences not tied to any particular device.
27///
28/// All fields are `#[serde(default)]` so adding a new one is backward
29/// compatible — old config files just keep the default for the new field.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[allow(
32    clippy::struct_excessive_bools,
33    reason = "independent on/off user preferences, not a state machine"
34)]
35pub struct AppSettings {
36    /// When true, a macOS `LaunchAgent` plist at
37    /// `~/Library/LaunchAgents/org.openlogi.openlogi.plist` is installed
38    /// so the app starts on login (P2.2). The plist is reconciled with
39    /// this field on every startup; flipping the flag and relaunching is
40    /// enough to install / remove it.
41    #[serde(default)]
42    pub launch_at_login: bool,
43    /// Opt-in update check (P2.8). **Off by default** to honour the
44    /// README's "no telemetry, no auto-update poller" promise. When true,
45    /// the app makes exactly one `HEAD /repos/AprilNEA/OpenLogi/releases/
46    /// latest` request per launch and logs whether a newer version is
47    /// available — no automatic download.
48    #[serde(default)]
49    pub check_for_updates: bool,
50    /// Opt-in automatic install. When true *and* [`Self::check_for_updates`]
51    /// surfaces a newer version, the GUI downloads and stages it in the
52    /// background; the update is applied on the next restart (never mid-session,
53    /// and never auto-relaunched). **Off by default** — it only acts after a
54    /// check the user already opted into, and stays inert in unsigned dev builds
55    /// where verification fails closed.
56    #[serde(default)]
57    pub auto_install_updates: bool,
58    /// True once the first-run "check for updates?" prompt has been answered
59    /// (either way), so it is never shown again. The prompt is how a
60    /// privacy-conscious default of `check_for_updates = false` still lets a
61    /// user opt in on first launch.
62    #[serde(default)]
63    pub update_prompt_seen: bool,
64    /// Whether OpenLogi shows a macOS menu-bar (status item) icon — and, on
65    /// Windows, the notification-area (tray) icon. `true` (default) → the
66    /// agent is visible in the menu bar / tray; `false` → it runs with no
67    /// visible presence (macOS additionally keeps the ordinary Dock icon
68    /// while a window is open). Ignored on Linux.
69    #[serde(default = "default_true")]
70    pub show_in_menu_bar: bool,
71    /// Whether the GUI automatically downloads device images from
72    /// `assets.openlogi.org` when a device appears. `true` (default) keeps
73    /// the current behavior; `false` makes no asset network requests at all
74    /// (the app falls back to bundled art and the synthetic silhouette). A
75    /// manual "Refresh assets" in Settings still fetches on demand regardless.
76    #[serde(default = "default_true")]
77    pub auto_download_assets: bool,
78    /// UI language as a BCP-47-ish locale code matching the GUI's bundled
79    /// locales (e.g. `"en"`, `"de"`, `"pt-BR"`, `"zh-CN"`, `"zh-TW"`; see the
80    /// GUI's `i18n::SUPPORTED`). `None` means "follow the system locale", which
81    /// the GUI resolves at startup. Stored here so a user's explicit choice
82    /// survives restarts regardless of the OS setting.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub language: Option<String>,
85    /// Thumb-wheel responsiveness, on a [`MIN_THUMBWHEEL_SENSITIVITY`]–
86    /// [`MAX_THUMBWHEEL_SENSITIVITY`] scale. It scales both the speed of the
87    /// wheel's continuous horizontal scroll and how few rotation increments a
88    /// custom wheel action needs to fire. [`DEFAULT_THUMBWHEEL_SENSITIVITY`]
89    /// (the out-of-the-box value) means 1× scroll speed; the wheel is only
90    /// diverted from native scrolling once this leaves the default.
91    #[serde(default = "default_thumbwheel_sensitivity")]
92    pub thumbwheel_sensitivity: i32,
93    /// Light/dark appearance preference. Defaults to following the OS.
94    #[serde(default)]
95    pub appearance: Appearance,
96    /// Name of the theme used in light mode (a [`crate`]-agnostic string
97    /// matching a gpui-component theme, e.g. `"OpenLogi Light"`). `None` uses
98    /// the OpenLogi brand light theme.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub theme_light: Option<String>,
101    /// Name of the theme used in dark mode. `None` uses the OpenLogi brand dark
102    /// theme.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub theme_dark: Option<String>,
105    /// Corner-radius override for the UI, in pixels (the Appearance page offers
106    /// `0` / `6` / `12`). `None` keeps each theme's own radius.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub ui_radius: Option<u8>,
109}
110
111/// Out-of-the-box [`AppSettings::thumbwheel_sensitivity`]. At this value the
112/// wheel's horizontal scroll runs at 1× and the wheel is left to scroll
113/// natively (no HID++ diversion) unless a binding diverges from its default.
114pub const DEFAULT_THUMBWHEEL_SENSITIVITY: i32 = 14;
115/// Lowest selectable [`AppSettings::thumbwheel_sensitivity`].
116pub const MIN_THUMBWHEEL_SENSITIVITY: i32 = 1;
117/// Highest selectable [`AppSettings::thumbwheel_sensitivity`].
118pub const MAX_THUMBWHEEL_SENSITIVITY: i32 = 100;
119
120impl AppSettings {
121    /// `skip_serializing_if` helper: true when nothing diverges from the
122    /// default, so empty settings don't clutter `config.toml`.
123    #[must_use]
124    pub fn is_default(&self) -> bool {
125        self == &Self::default()
126    }
127}
128
129impl Default for AppSettings {
130    fn default() -> Self {
131        Self {
132            launch_at_login: false,
133            check_for_updates: false,
134            auto_install_updates: false,
135            update_prompt_seen: false,
136            show_in_menu_bar: true,
137            auto_download_assets: true,
138            language: None,
139            thumbwheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY,
140            appearance: Appearance::System,
141            theme_light: None,
142            theme_dark: None,
143            ui_radius: None,
144        }
145    }
146}
147
148/// serde default for [`AppSettings::show_in_menu_bar`]: `true`, so the menu-bar
149/// icon is on out of the box and configs predating the field keep that behavior.
150fn default_true() -> bool {
151    true
152}
153
154/// serde default for [`AppSettings::thumbwheel_sensitivity`]: keeps configs
155/// predating the field at the 1× default.
156const fn default_thumbwheel_sensitivity() -> i32 {
157    DEFAULT_THUMBWHEEL_SENSITIVITY
158}
159
160/// Per-device RGB lighting: a single static color, brightness, and on/off.
161/// Deliberately basic — per-key effects are a later addition.
162///
163/// Crosses the agent↔GUI IPC (`set_lighting`), so field order is wire format —
164/// changes require a `PROTOCOL_VERSION` bump (guarded by
165/// `openlogi-agent-core/tests/wire_format.rs`).
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct Lighting {
168    /// Master on/off for the device's lighting. The color and brightness
169    /// persist while disabled, so re-enabling restores the previous look.
170    #[serde(default = "default_lighting_enabled")]
171    pub enabled: bool,
172    /// Static color as 6 hex digits `"RRGGBB"` (no leading `#`). A value
173    /// that does not parse falls back to white on load — the same per-field
174    /// tolerance as `brightness`, because failing the whole load would
175    /// discard the user's entire config (see the `load_or_default` callers).
176    #[serde(
177        default = "default_lighting_color",
178        deserialize_with = "deserialize_lighting_color"
179    )]
180    pub color: Rgb,
181    /// Brightness percent, clamped to 0–100 on load.
182    #[serde(
183        default = "default_lighting_brightness",
184        deserialize_with = "deserialize_brightness"
185    )]
186    pub brightness: u8,
187}
188
189impl Default for Lighting {
190    fn default() -> Self {
191        Self {
192            enabled: default_lighting_enabled(),
193            color: default_lighting_color(),
194            brightness: default_lighting_brightness(),
195        }
196    }
197}
198
199fn default_lighting_enabled() -> bool {
200    true
201}
202
203fn default_lighting_color() -> Rgb {
204    Rgb::WHITE
205}
206
207fn default_lighting_brightness() -> u8 {
208    100
209}
210
211/// Clamp a deserialized brightness into the UI's `0..=100` range, so a
212/// hand-edited `config.toml` can't feed out-of-range values into the scaling
213/// math (which assumes `brightness <= 100`).
214fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
215where
216    D: serde::Deserializer<'de>,
217{
218    Ok(u8::deserialize(deserializer)?.min(100))
219}
220
221/// Accept the optional `#` prefix supported by older releases, then fall back
222/// to white when the configured color does not parse, mirroring the `brightness`
223/// clamp above instead of failing the whole config load.
224fn deserialize_lighting_color<'de, D>(deserializer: D) -> Result<Rgb, D::Error>
225where
226    D: serde::Deserializer<'de>,
227{
228    let color = String::deserialize(deserializer)?;
229    Ok(color
230        .strip_prefix('#')
231        .unwrap_or(color.as_str())
232        .parse()
233        .unwrap_or(Rgb::WHITE))
234}
235
236/// Scroll-wheel mode for [`SmartShift`]: free-spin or ratchet (clicky).
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(rename_all = "snake_case")]
239pub enum WheelMode {
240    /// Free-spin — the wheel rotates without détentes.
241    Free,
242    /// Ratchet (clicky) scrolling. With SmartShift enabled the firmware
243    /// auto-releases into free-spin past the configured
244    /// [`auto_disengage`](SmartShift::auto_disengage) speed.
245    Ratchet,
246}
247
248/// SmartShift auto-disengage out-of-box default (`16` ≈ 4 turn/s, per the
249/// x2110 / x2111 spec). The sensitivity slider's default and the heal target
250/// for a corrupt persisted threshold.
251pub const SMARTSHIFT_AUTO_DISENGAGE_DEFAULT: u8 = 16;
252
253/// Smallest auto-disengage threshold OpenLogi will store or apply (`8` ≈
254/// 2 turn/s). Below this the ratchet releases into free-spin at everyday scroll
255/// speeds, leaving the wheel "stuck" spinning (#317); `0` is also the firmware
256/// "do not change" sentinel that must never be stored as a real value. A
257/// persisted threshold below this floor is a corrupt artifact and is healed to
258/// [`SMARTSHIFT_AUTO_DISENGAGE_DEFAULT`] on load.
259pub const SMARTSHIFT_MIN_AUTO_DISENGAGE: u8 = 8;
260
261/// Heal a persisted auto-disengage threshold on load: anything below
262/// [`SMARTSHIFT_MIN_AUTO_DISENGAGE`] (including the `0` sentinel) becomes the
263/// default. `0xFF` (permanent ratchet) and every real threshold at or above the
264/// floor pass through unchanged.
265fn deserialize_auto_disengage<'de, D>(deserializer: D) -> Result<u8, D::Error>
266where
267    D: serde::Deserializer<'de>,
268{
269    let value = u8::deserialize(deserializer)?;
270    Ok(if value < SMARTSHIFT_MIN_AUTO_DISENGAGE {
271        tracing::warn!(
272            value,
273            min = SMARTSHIFT_MIN_AUTO_DISENGAGE,
274            default = SMARTSHIFT_AUTO_DISENGAGE_DEFAULT,
275            "healed persisted SmartShift auto-disengage threshold below supported floor"
276        );
277        SMARTSHIFT_AUTO_DISENGAGE_DEFAULT
278    } else {
279        value
280    })
281}
282
283/// Per-device SmartShift wheel configuration, persisted so the agent can
284/// re-apply it when the device reconnects: the values are written to device
285/// RAM and do not survive a power cycle (#189), despite earlier assumptions
286/// that the device kept them in NVM.
287///
288/// Config-file only — never crosses the IPC (the agent reads it from
289/// `config.toml` on reload), so it is free to evolve without a
290/// `PROTOCOL_VERSION` bump.
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
292pub struct SmartShift {
293    /// The persisted wheel mode, re-applied to device RAM on reconnect.
294    pub mode: WheelMode,
295    /// SmartShift auto-disengage threshold (`0x08`–`0xFE`, in 0.25 turn/s
296    /// steps), or `0xFF` for a permanently engaged ratchet. A persisted value
297    /// below [`SMARTSHIFT_MIN_AUTO_DISENGAGE`] is healed to the default on load.
298    #[serde(deserialize_with = "deserialize_auto_disengage")]
299    pub auto_disengage: u8,
300    /// Tunable-torque force percentage (`1`–`100`), `0` when the device
301    /// doesn't support tunable torque.
302    pub tunable_torque: u8,
303}
304
305/// Which control owns a device's single gesture role.
306///
307/// Stored explicitly — rather than inferred from which button happens to carry a
308/// [`Binding::Gesture`](crate::binding::Binding::Gesture) — so switching the
309/// gesture button never has to collapse a button's gesture map to encode the
310/// choice: every gesture-capable button keeps its full direction map, and only
311/// the owner is dispatched. Serialized as a bare string (`"Off"` or a
312/// [`ButtonId`] name) so it stays a TOML scalar.
313#[derive(Clone, Copy, Debug, PartialEq, Eq)]
314pub enum GestureOwner {
315    /// Gestures are explicitly turned off for this device.
316    Off,
317    /// The named button owns the gesture role.
318    Button(ButtonId),
319}
320
321impl Serialize for GestureOwner {
322    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
323        match self {
324            // "Off" can't collide with a ButtonId variant name (all CamelCase
325            // control names), so the string space is unambiguous.
326            GestureOwner::Off => serializer.serialize_str("Off"),
327            GestureOwner::Button(id) => id.serialize(serializer),
328        }
329    }
330}
331
332/// Lenient field deserializer for `RawDeviceConfig::gesture_owner`
333/// (`crate::config::device`). An unrecognized or miscased value (`"back"`, a
334/// typo, a future-version button name) is treated as absent — i.e. "infer the
335/// owner" — rather than failing the whole-document parse and reverting *every*
336/// device's settings to defaults. Mirrors [`deserialize_brightness`], which
337/// clamps a bad value instead of erroring; a hand-editable config should
338/// degrade one field, not the document.
339pub(super) fn deserialize_gesture_owner<'de, D>(
340    deserializer: D,
341) -> Result<Option<GestureOwner>, D::Error>
342where
343    D: serde::Deserializer<'de>,
344{
345    let s = String::deserialize(deserializer)?;
346    if s == "Off" {
347        return Ok(Some(GestureOwner::Off));
348    }
349    // Parse the button name with a throwaway error type so an unknown token maps
350    // to `None` (infer) rather than propagating an error.
351    let button = ButtonId::deserialize(
352        serde::de::value::StrDeserializer::<serde::de::value::Error>::new(&s),
353    )
354    .ok();
355    Ok(button.map(GestureOwner::Button))
356}
357
358#[cfg(test)]
359#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn low_auto_disengage_heals_to_default_on_load() {
365        // A pre-#317 config could persist a runaway-low threshold (or the `0`
366        // sentinel); loading it must heal to the default so reapply doesn't
367        // re-program free-spin-on-any-scroll into the device — while a real
368        // threshold and the `0xFF` permanent-ratchet value pass through.
369        let heal = |v: u8| {
370            let body = format!("mode = \"ratchet\"\nauto_disengage = {v}\ntunable_torque = 50\n");
371            toml::from_str::<SmartShift>(&body)
372                .expect("parse")
373                .auto_disengage
374        };
375        assert_eq!(heal(0), SMARTSHIFT_AUTO_DISENGAGE_DEFAULT);
376        assert_eq!(heal(1), SMARTSHIFT_AUTO_DISENGAGE_DEFAULT);
377        assert_eq!(
378            heal(SMARTSHIFT_MIN_AUTO_DISENGAGE - 1),
379            SMARTSHIFT_AUTO_DISENGAGE_DEFAULT
380        );
381        assert_eq!(
382            heal(SMARTSHIFT_MIN_AUTO_DISENGAGE),
383            SMARTSHIFT_MIN_AUTO_DISENGAGE
384        );
385        assert_eq!(heal(16), 16);
386        assert_eq!(heal(0xff), 0xff);
387    }
388}