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 agent installs the OS-level mouse hook (CGEventTap /
93 /// exclusive `evdev` grab / `WH_MOUSE_LL`) that intercepts mouse events
94 /// for button remapping. `true` (default) keeps remapping active;
95 /// `false` is an escape hatch that leaves every input device untouched
96 /// (on Linux: no exclusive grabs at all; on macOS the agent also skips
97 /// the startup Accessibility prompt). HID++-side features — DPI,
98 /// SmartShift, the gesture button, the thumb wheel — are unaffected.
99 /// Takes effect on agent restart.
100 #[serde(default = "default_true")]
101 pub capture_mouse_events: bool,
102 /// Whether the GUI automatically downloads device images from
103 /// `assets.openlogi.org` when a device appears. `true` (default) keeps
104 /// the current behavior; `false` makes no asset network requests at all
105 /// (the app falls back to bundled art and the synthetic silhouette). A
106 /// manual "Refresh assets" in Settings still fetches on demand regardless.
107 /// Whether the GUI automatically downloads device images from the selected
108 /// source when a device appears. `true` (default) keeps the current behavior;
109 /// `false` makes no asset network requests at all (the app falls back to
110 /// bundled art and the synthetic silhouette). A manual "Refresh assets" in
111 /// Settings still fetches on demand regardless.
112 #[serde(default = "default_true")]
113 pub auto_download_assets: bool,
114 /// Preferred mirror for automatic and manual device-asset downloads.
115 /// Defaults to racing all built-in mirrors; `OPENLOGI_ASSETS` remains a
116 /// process-level override for development and diagnostics.
117 #[serde(default)]
118 pub asset_source: AssetSourcePreference,
119 /// UI language as a BCP-47-ish locale code matching the GUI's bundled
120 /// locales (e.g. `"en"`, `"de"`, `"pt-BR"`, `"zh-CN"`, `"zh-TW"`; see the
121 /// GUI's `i18n::SUPPORTED`). `None` means "follow the system locale", which
122 /// the GUI resolves at startup. Stored here so a user's explicit choice
123 /// survives restarts regardless of the OS setting.
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub language: Option<String>,
126 /// Thumb-wheel responsiveness, on a [`MIN_THUMBWHEEL_SENSITIVITY`]–
127 /// [`MAX_THUMBWHEEL_SENSITIVITY`] scale. It scales both the speed of the
128 /// wheel's continuous horizontal scroll and how few rotation increments a
129 /// custom wheel action needs to fire. [`DEFAULT_THUMBWHEEL_SENSITIVITY`]
130 /// (the out-of-the-box value) means 1× scroll speed; the wheel is only
131 /// diverted from native scrolling once this leaves the default.
132 #[serde(default = "default_thumbwheel_sensitivity")]
133 pub thumbwheel_sensitivity: i32,
134 /// Light/dark appearance preference. Defaults to following the OS.
135 #[serde(default)]
136 pub appearance: Appearance,
137 /// Name of the theme used in light mode (a [`crate`]-agnostic string
138 /// matching a gpui-component theme, e.g. `"OpenLogi Light"`). `None` uses
139 /// the OpenLogi brand light theme.
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub theme_light: Option<String>,
142 /// Name of the theme used in dark mode. `None` uses the OpenLogi brand dark
143 /// theme.
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub theme_dark: Option<String>,
146 /// Corner-radius override for the UI, in pixels (the Appearance page offers
147 /// `0` / `6` / `12`). `None` keeps each theme's own radius.
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub ui_radius: Option<u8>,
150}
151
152/// Out-of-the-box [`AppSettings::thumbwheel_sensitivity`]. At this value the
153/// wheel's horizontal scroll runs at 1× and the wheel is left to scroll
154/// natively (no HID++ diversion) unless a binding diverges from its default.
155pub const DEFAULT_THUMBWHEEL_SENSITIVITY: i32 = 14;
156/// Lowest selectable [`AppSettings::thumbwheel_sensitivity`].
157pub const MIN_THUMBWHEEL_SENSITIVITY: i32 = 1;
158/// Highest selectable [`AppSettings::thumbwheel_sensitivity`].
159pub const MAX_THUMBWHEEL_SENSITIVITY: i32 = 100;
160
161impl AppSettings {
162 /// `skip_serializing_if` helper: true when nothing diverges from the
163 /// default, so empty settings don't clutter `config.toml`.
164 #[must_use]
165 pub fn is_default(&self) -> bool {
166 self == &Self::default()
167 }
168}
169
170impl Default for AppSettings {
171 fn default() -> Self {
172 Self {
173 launch_at_login: false,
174 check_for_updates: false,
175 auto_install_updates: false,
176 update_prompt_seen: false,
177 show_in_menu_bar: true,
178 capture_mouse_events: true,
179 auto_download_assets: true,
180 asset_source: AssetSourcePreference::Automatic,
181 language: None,
182 thumbwheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY,
183 appearance: Appearance::System,
184 theme_light: None,
185 theme_dark: None,
186 ui_radius: None,
187 }
188 }
189}
190
191/// serde default for the on-by-default [`AppSettings`] toggles
192/// ([`AppSettings::show_in_menu_bar`], [`AppSettings::capture_mouse_events`],
193/// [`AppSettings::auto_download_assets`]), so configs predating a field keep the
194/// out-of-the-box behavior.
195fn default_true() -> bool {
196 true
197}
198
199/// serde default for [`AppSettings::thumbwheel_sensitivity`]: keeps configs
200/// predating the field at the 1× default.
201const fn default_thumbwheel_sensitivity() -> i32 {
202 DEFAULT_THUMBWHEEL_SENSITIVITY
203}
204
205/// Per-device RGB lighting: a single static color, brightness, and on/off.
206/// Deliberately basic — per-key effects are a later addition.
207///
208/// Crosses the agent↔GUI IPC (`set_lighting`), so field order is wire format —
209/// changes require a `PROTOCOL_VERSION` bump (guarded by
210/// `openlogi-agent-core/tests/wire_format.rs`).
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct Lighting {
213 /// Master on/off for the device's lighting. The color and brightness
214 /// persist while disabled, so re-enabling restores the previous look.
215 #[serde(default = "default_lighting_enabled")]
216 pub enabled: bool,
217 /// Static color as 6 hex digits `"RRGGBB"` (no leading `#`). A value
218 /// that does not parse falls back to white on load — the same per-field
219 /// tolerance as `brightness`, because failing the whole load would
220 /// discard the user's entire config (see the `load_or_default` callers).
221 #[serde(
222 default = "default_lighting_color",
223 deserialize_with = "deserialize_lighting_color"
224 )]
225 pub color: Rgb,
226 /// Brightness percent, clamped to 0–100 on load.
227 #[serde(
228 default = "default_lighting_brightness",
229 deserialize_with = "deserialize_brightness"
230 )]
231 pub brightness: u8,
232}
233
234impl Default for Lighting {
235 fn default() -> Self {
236 Self {
237 enabled: default_lighting_enabled(),
238 color: default_lighting_color(),
239 brightness: default_lighting_brightness(),
240 }
241 }
242}
243
244fn default_lighting_enabled() -> bool {
245 true
246}
247
248fn default_lighting_color() -> Rgb {
249 Rgb::WHITE
250}
251
252fn default_lighting_brightness() -> u8 {
253 100
254}
255
256/// Clamp a deserialized brightness into the UI's `0..=100` range, so a
257/// hand-edited `config.toml` can't feed out-of-range values into the scaling
258/// math (which assumes `brightness <= 100`).
259fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
260where
261 D: serde::Deserializer<'de>,
262{
263 Ok(u8::deserialize(deserializer)?.min(100))
264}
265
266/// Accept the optional `#` prefix supported by older releases, then fall back
267/// to white when the configured color does not parse, mirroring the `brightness`
268/// clamp above instead of failing the whole config load.
269fn deserialize_lighting_color<'de, D>(deserializer: D) -> Result<Rgb, D::Error>
270where
271 D: serde::Deserializer<'de>,
272{
273 let color = String::deserialize(deserializer)?;
274 Ok(color
275 .strip_prefix('#')
276 .unwrap_or(color.as_str())
277 .parse()
278 .unwrap_or(Rgb::WHITE))
279}
280
281/// Vertical wheel reporting resolution for HID++ `0x2121 HiResWheel`.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "snake_case")]
284pub enum ScrollResolution {
285 /// One scroll report per physical ratchet step.
286 Low,
287 /// Finer-grained reports between physical ratchet steps.
288 High,
289}
290
291/// Scroll-wheel mode for [`SmartShift`]: free-spin or ratchet (clicky).
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "snake_case")]
294pub enum WheelMode {
295 /// Free-spin — the wheel rotates without détentes.
296 Free,
297 /// Ratchet (clicky) scrolling. With SmartShift enabled the firmware
298 /// auto-releases into free-spin past the configured
299 /// [`auto_disengage`](SmartShift::auto_disengage) speed.
300 Ratchet,
301}
302
303/// SmartShift auto-disengage out-of-box default (`16` ≈ 4 turn/s, per the
304/// x2110 / x2111 spec). The sensitivity slider's default and the heal target
305/// for a corrupt persisted threshold.
306pub const SMARTSHIFT_AUTO_DISENGAGE_DEFAULT: u8 = 16;
307
308/// Smallest auto-disengage threshold OpenLogi will store or apply (`8` ≈
309/// 2 turn/s). Below this the ratchet releases into free-spin at everyday scroll
310/// speeds, leaving the wheel "stuck" spinning (#317); `0` is also the firmware
311/// "do not change" sentinel that must never be stored as a real value. A
312/// persisted threshold below this floor is a corrupt artifact and is healed to
313/// [`SMARTSHIFT_AUTO_DISENGAGE_DEFAULT`] on load.
314pub const SMARTSHIFT_MIN_AUTO_DISENGAGE: u8 = 8;
315
316/// Heal a persisted auto-disengage threshold on load: anything below
317/// [`SMARTSHIFT_MIN_AUTO_DISENGAGE`] (including the `0` sentinel) becomes the
318/// default. `0xFF` (permanent ratchet) and every real threshold at or above the
319/// floor pass through unchanged.
320fn deserialize_auto_disengage<'de, D>(deserializer: D) -> Result<u8, D::Error>
321where
322 D: serde::Deserializer<'de>,
323{
324 let value = u8::deserialize(deserializer)?;
325 Ok(if value < SMARTSHIFT_MIN_AUTO_DISENGAGE {
326 tracing::warn!(
327 value,
328 min = SMARTSHIFT_MIN_AUTO_DISENGAGE,
329 default = SMARTSHIFT_AUTO_DISENGAGE_DEFAULT,
330 "healed persisted SmartShift auto-disengage threshold below supported floor"
331 );
332 SMARTSHIFT_AUTO_DISENGAGE_DEFAULT
333 } else {
334 value
335 })
336}
337
338/// Per-device SmartShift wheel configuration, persisted so the agent can
339/// re-apply it when the device reconnects: the values are written to device
340/// RAM and do not survive a power cycle (#189), despite earlier assumptions
341/// that the device kept them in NVM.
342///
343/// Config-file only — never crosses the IPC (the agent reads it from
344/// `config.toml` on reload), so it is free to evolve without a
345/// `PROTOCOL_VERSION` bump.
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
347pub struct SmartShift {
348 /// The persisted wheel mode, re-applied to device RAM on reconnect.
349 pub mode: WheelMode,
350 /// SmartShift auto-disengage threshold (`0x08`–`0xFE`, in 0.25 turn/s
351 /// steps), or `0xFF` for a permanently engaged ratchet. A persisted value
352 /// below [`SMARTSHIFT_MIN_AUTO_DISENGAGE`] is healed to the default on load.
353 #[serde(deserialize_with = "deserialize_auto_disengage")]
354 pub auto_disengage: u8,
355 /// Tunable-torque force percentage (`1`–`100`), `0` when the device
356 /// doesn't support tunable torque.
357 pub tunable_torque: u8,
358}
359
360/// Which control owns a device's single gesture role.
361///
362/// Stored explicitly — rather than inferred from which button happens to carry a
363/// [`Binding::Gesture`](crate::binding::Binding::Gesture) — so switching the
364/// gesture button never has to collapse a button's gesture map to encode the
365/// choice: every gesture-capable button keeps its full direction map, and only
366/// the owner is dispatched. Serialized as a bare string (`"Off"` or a
367/// [`ButtonId`] name) so it stays a TOML scalar.
368#[derive(Clone, Copy, Debug, PartialEq, Eq)]
369pub enum GestureOwner {
370 /// Gestures are explicitly turned off for this device.
371 Off,
372 /// The named button owns the gesture role.
373 Button(ButtonId),
374}
375
376impl Serialize for GestureOwner {
377 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
378 match self {
379 // "Off" can't collide with a ButtonId variant name (all CamelCase
380 // control names), so the string space is unambiguous.
381 GestureOwner::Off => serializer.serialize_str("Off"),
382 GestureOwner::Button(id) => id.serialize(serializer),
383 }
384 }
385}
386
387/// Lenient field deserializer for `RawDeviceConfig::gesture_owner`
388/// (`crate::config::device`). An unrecognized or miscased value (`"back"`, a
389/// typo, a future-version button name) is treated as absent — i.e. "infer the
390/// owner" — rather than failing the whole-document parse and reverting *every*
391/// device's settings to defaults. Mirrors [`deserialize_brightness`], which
392/// clamps a bad value instead of erroring; a hand-editable config should
393/// degrade one field, not the document.
394pub(super) fn deserialize_gesture_owner<'de, D>(
395 deserializer: D,
396) -> Result<Option<GestureOwner>, D::Error>
397where
398 D: serde::Deserializer<'de>,
399{
400 let s = String::deserialize(deserializer)?;
401 if s == "Off" {
402 return Ok(Some(GestureOwner::Off));
403 }
404 // Parse the button name with a throwaway error type so an unknown token maps
405 // to `None` (infer) rather than propagating an error.
406 let button = ButtonId::deserialize(
407 serde::de::value::StrDeserializer::<serde::de::value::Error>::new(&s),
408 )
409 .ok();
410 Ok(button.map(GestureOwner::Button))
411}
412
413#[cfg(test)]
414#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn low_auto_disengage_heals_to_default_on_load() {
420 // A pre-#317 config could persist a runaway-low threshold (or the `0`
421 // sentinel); loading it must heal to the default so reapply doesn't
422 // re-program free-spin-on-any-scroll into the device — while a real
423 // threshold and the `0xFF` permanent-ratchet value pass through.
424 let heal = |v: u8| {
425 let body = format!("mode = \"ratchet\"\nauto_disengage = {v}\ntunable_torque = 50\n");
426 toml::from_str::<SmartShift>(&body)
427 .expect("parse")
428 .auto_disengage
429 };
430 assert_eq!(heal(0), SMARTSHIFT_AUTO_DISENGAGE_DEFAULT);
431 assert_eq!(heal(1), SMARTSHIFT_AUTO_DISENGAGE_DEFAULT);
432 assert_eq!(
433 heal(SMARTSHIFT_MIN_AUTO_DISENGAGE - 1),
434 SMARTSHIFT_AUTO_DISENGAGE_DEFAULT
435 );
436 assert_eq!(
437 heal(SMARTSHIFT_MIN_AUTO_DISENGAGE),
438 SMARTSHIFT_MIN_AUTO_DISENGAGE
439 );
440 assert_eq!(heal(16), 16);
441 assert_eq!(heal(0xff), 0xff);
442 }
443}