use std::collections::BTreeMap;
use az::SaturatingAs;
use nutype::nutype;
use serde::{Deserialize, Serialize};
use crate::binding::ButtonId;
use crate::color::Rgb;
use crate::hid::{SmartShiftAutoDisengage, SmartShiftThreshold, TunableTorque};
#[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)]
pub thumbwheel_sensitivity: ThumbwheelSensitivity,
#[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>,
}
#[nutype(
const_fn,
validate(greater_or_equal = 1, less_or_equal = 100),
derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
TryFrom,
Into,
Display,
Serialize,
Deserialize
)
)]
pub struct ThumbwheelSensitivity(u8);
impl ThumbwheelSensitivity {
pub const MIN: Self = match Self::try_new(1) {
Ok(value) => value,
Err(_) => panic!("valid minimum thumb-wheel sensitivity"),
};
pub const MAX: Self = match Self::try_new(100) {
Ok(value) => value,
Err(_) => panic!("valid maximum thumb-wheel sensitivity"),
};
pub const DEFAULT: Self = match Self::try_new(14) {
Ok(value) => value,
Err(_) => panic!("valid default thumb-wheel sensitivity"),
};
#[must_use]
pub fn from_rounded(value: f32) -> Self {
let value = if value.is_nan() {
f32::from(Self::MIN)
} else {
value
};
let raw = value
.clamp(f32::from(Self::MIN), f32::from(Self::MAX))
.round()
.saturating_as::<u8>();
let Ok(value) = Self::try_new(raw) else {
unreachable!("clamped thumb-wheel sensitivity is always valid");
};
value
}
#[must_use]
pub fn scroll_multiplier(self) -> f32 {
f32::from(self) / f32::from(Self::DEFAULT)
}
#[must_use]
pub fn action_threshold(self) -> i32 {
(2 * i32::from(Self::DEFAULT) - i32::from(self)).max(1)
}
}
impl Default for ThumbwheelSensitivity {
fn default() -> Self {
Self::DEFAULT
}
}
impl From<ThumbwheelSensitivity> for f32 {
fn from(sensitivity: ThumbwheelSensitivity) -> Self {
Self::from(sensitivity.into_inner())
}
}
impl From<ThumbwheelSensitivity> for i32 {
fn from(sensitivity: ThumbwheelSensitivity) -> Self {
Self::from(sensitivity.into_inner())
}
}
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: ThumbwheelSensitivity::DEFAULT,
appearance: Appearance::System,
theme_light: None,
theme_dark: None,
ui_radius: None,
}
}
}
fn default_true() -> bool {
true
}
#[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: SmartShiftThreshold =
match SmartShiftThreshold::try_new(16) {
Ok(value) => value,
Err(_) => panic!("valid default SmartShift threshold"),
};
pub const SMARTSHIFT_MIN_AUTO_DISENGAGE: SmartShiftThreshold = match SmartShiftThreshold::try_new(8)
{
Ok(value) => value,
Err(_) => panic!("valid minimum SmartShift threshold"),
};
fn deserialize_auto_disengage<'de, D>(deserializer: D) -> Result<SmartShiftAutoDisengage, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = SmartShiftAutoDisengage::deserialize(deserializer)?;
match value {
SmartShiftAutoDisengage::Threshold(threshold)
if threshold < SMARTSHIFT_MIN_AUTO_DISENGAGE =>
{
Err(serde::de::Error::custom(format_args!(
"SmartShift auto_disengage must be between {SMARTSHIFT_MIN_AUTO_DISENGAGE} and 255, got {threshold}"
)))
}
_ => Ok(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: SmartShiftAutoDisengage,
#[serde(with = "crate::hid::smartshift::optional_tunable_torque")]
pub tunable_torque: Option<TunableTorque>,
}
#[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)
};
let minimum = u8::from(SMARTSHIFT_MIN_AUTO_DISENGAGE);
parse(minimum - 1, 50)
.expect_err("auto_disengage below the persisted minimum must be rejected");
parse(minimum, 50).expect("the minimum itself is in contract");
parse(0xff, 0xff).expect("the top of both ranges is in contract");
assert_eq!(
parse(minimum, 0)
.expect("zero torque represents unsupported hardware")
.tunable_torque,
None
);
}
#[test]
fn floating_thumbwheel_sensitivity_rounds_and_saturates_into_the_domain() {
assert_eq!(u8::from(ThumbwheelSensitivity::from_rounded(49.6)), 50);
assert_eq!(
ThumbwheelSensitivity::from_rounded(f32::NAN),
ThumbwheelSensitivity::MIN
);
assert_eq!(
ThumbwheelSensitivity::from_rounded(f32::NEG_INFINITY),
ThumbwheelSensitivity::MIN
);
assert_eq!(
ThumbwheelSensitivity::from_rounded(f32::INFINITY),
ThumbwheelSensitivity::MAX
);
}
}