Skip to main content

openlogi_core/config/
settings.rs

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