use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::binding::ButtonId;
use crate::color::Rgb;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Appearance {
#[default]
System,
Light,
Dark,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AssetSourcePreference {
#[default]
Automatic,
#[serde(rename = "openlogi")]
OpenLogi,
Cloudflare,
Fastly,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[allow(
clippy::struct_excessive_bools,
reason = "independent on/off user preferences, not a state machine"
)]
pub struct AppSettings {
#[serde(default)]
pub launch_at_login: bool,
#[serde(default)]
pub check_for_updates: bool,
#[serde(default)]
pub auto_install_updates: bool,
#[serde(default)]
pub update_prompt_seen: bool,
#[serde(default = "default_true")]
pub show_in_menu_bar: bool,
#[serde(default = "default_true")]
pub capture_mouse_events: bool,
#[serde(default = "default_true")]
pub auto_download_assets: bool,
#[serde(default)]
pub asset_source: AssetSourcePreference,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(
default = "default_thumbwheel_sensitivity",
deserialize_with = "deserialize_thumbwheel_sensitivity"
)]
pub thumbwheel_sensitivity: i32,
#[serde(default)]
pub appearance: Appearance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme_light: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme_dark: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_radius: Option<u8>,
}
pub const DEFAULT_THUMBWHEEL_SENSITIVITY: i32 = 14;
pub const MIN_THUMBWHEEL_SENSITIVITY: i32 = 1;
pub const MAX_THUMBWHEEL_SENSITIVITY: i32 = 100;
#[must_use]
pub fn clamp_thumbwheel_sensitivity(value: i32) -> i32 {
value.clamp(MIN_THUMBWHEEL_SENSITIVITY, MAX_THUMBWHEEL_SENSITIVITY)
}
impl AppSettings {
#[must_use]
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
impl Default for AppSettings {
fn default() -> Self {
Self {
launch_at_login: false,
check_for_updates: false,
auto_install_updates: false,
update_prompt_seen: false,
show_in_menu_bar: true,
capture_mouse_events: true,
auto_download_assets: true,
asset_source: AssetSourcePreference::Automatic,
language: None,
thumbwheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY,
appearance: Appearance::System,
theme_light: None,
theme_dark: None,
ui_radius: None,
}
}
}
fn default_true() -> bool {
true
}
const fn default_thumbwheel_sensitivity() -> i32 {
DEFAULT_THUMBWHEEL_SENSITIVITY
}
pub(super) fn deserialize_thumbwheel_sensitivity<'de, D>(deserializer: D) -> Result<i32, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = i32::deserialize(deserializer)?;
if (MIN_THUMBWHEEL_SENSITIVITY..=MAX_THUMBWHEEL_SENSITIVITY).contains(&value) {
Ok(value)
} else {
Err(serde::de::Error::custom(format_args!(
"thumbwheel sensitivity must be between {MIN_THUMBWHEEL_SENSITIVITY} and {MAX_THUMBWHEEL_SENSITIVITY}, got {value}"
)))
}
}
pub(super) fn deserialize_optional_thumbwheel_sensitivity<'de, D>(
deserializer: D,
) -> Result<Option<i32>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<i32>::deserialize(deserializer)?;
value
.map(|value| {
if (MIN_THUMBWHEEL_SENSITIVITY..=MAX_THUMBWHEEL_SENSITIVITY).contains(&value) {
Ok(value)
} else {
Err(serde::de::Error::custom(format_args!(
"thumbwheel sensitivity must be between {MIN_THUMBWHEEL_SENSITIVITY} and {MAX_THUMBWHEEL_SENSITIVITY}, got {value}"
)))
}
})
.transpose()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Lighting {
#[serde(default = "default_lighting_enabled")]
pub enabled: bool,
#[serde(
default = "default_lighting_color",
deserialize_with = "deserialize_lighting_color"
)]
pub color: Rgb,
#[serde(
default = "default_lighting_brightness",
deserialize_with = "deserialize_brightness"
)]
pub brightness: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LightSettings {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub auto_camera: bool,
#[serde(
default = "default_light_brightness",
deserialize_with = "deserialize_brightness"
)]
pub brightness_percent: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temperature_kelvin: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<Rgb>,
}
const fn default_light_brightness() -> u8 {
100
}
impl Default for LightSettings {
fn default() -> Self {
Self {
enabled: true,
auto_camera: false,
brightness_percent: default_light_brightness(),
temperature_kelvin: None,
color: None,
}
}
}
impl LightSettings {
#[must_use]
pub fn new(enabled: bool, brightness_percent: u8, temperature_kelvin: Option<u16>) -> Self {
Self {
enabled,
auto_camera: false,
brightness_percent: brightness_percent.min(100),
temperature_kelvin,
color: None,
}
}
}
#[allow(
clippy::trivially_copy_pass_by_ref,
reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
)]
const fn is_false(value: &bool) -> bool {
!*value
}
impl Default for Lighting {
fn default() -> Self {
Self {
enabled: default_lighting_enabled(),
color: default_lighting_color(),
brightness: default_lighting_brightness(),
}
}
}
fn default_lighting_enabled() -> bool {
true
}
fn default_lighting_color() -> Rgb {
Rgb::WHITE
}
fn default_lighting_brightness() -> u8 {
100
}
fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = u8::deserialize(deserializer)?;
if value <= 100 {
Ok(value)
} else {
Err(serde::de::Error::custom(format_args!(
"brightness must be between 0 and 100, got {value}"
)))
}
}
fn deserialize_lighting_color<'de, D>(deserializer: D) -> Result<Rgb, D::Error>
where
D: serde::Deserializer<'de>,
{
let color = String::deserialize(deserializer)?;
color
.strip_prefix('#')
.unwrap_or(color.as_str())
.parse()
.map_err(serde::de::Error::custom)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CameraControls(pub BTreeMap<String, i32>);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScrollResolution {
Low,
High,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WheelMode {
Free,
Ratchet,
}
pub const SMARTSHIFT_AUTO_DISENGAGE_DEFAULT: u8 = 16;
pub const SMARTSHIFT_MIN_AUTO_DISENGAGE: u8 = 8;
fn deserialize_auto_disengage<'de, D>(deserializer: D) -> Result<u8, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = u8::deserialize(deserializer)?;
if value >= SMARTSHIFT_MIN_AUTO_DISENGAGE {
Ok(value)
} else {
Err(serde::de::Error::custom(format_args!(
"SmartShift auto_disengage must be between {SMARTSHIFT_MIN_AUTO_DISENGAGE} and 255, got {value}"
)))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SmartShift {
pub mode: WheelMode,
#[serde(deserialize_with = "deserialize_auto_disengage")]
pub auto_disengage: u8,
pub tunable_torque: u8,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum GestureOwner {
Off,
Button(ButtonId),
}
pub(super) fn deserialize_gesture_owner<'de, D>(
deserializer: D,
) -> Result<Option<GestureOwner>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if s == "Off" {
return Ok(Some(GestureOwner::Off));
}
let button = ButtonId::deserialize(
serde::de::value::StrDeserializer::<serde::de::value::Error>::new(&s),
)
.ok();
Ok(button.map(GestureOwner::Button))
}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
mod tests {
use super::*;
#[test]
fn smartshift_rejects_values_outside_the_persisted_contract() {
let parse = |auto_disengage: u8, tunable_torque: u8| {
let body = format!(
"mode = \"ratchet\"\nauto_disengage = {auto_disengage}\ntunable_torque = {tunable_torque}\n"
);
toml::from_str::<SmartShift>(&body)
};
parse(SMARTSHIFT_MIN_AUTO_DISENGAGE - 1, 50)
.expect_err("auto_disengage below the persisted minimum must be rejected");
parse(SMARTSHIFT_MIN_AUTO_DISENGAGE, 50).expect("the minimum itself is in contract");
parse(0xff, 0xff).expect("the top of both ranges is in contract");
}
}