1use std::{
10 collections::BTreeMap,
11 fs, io,
12 path::{Path, PathBuf},
13};
14
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17
18use crate::binding::{Action, Binding, ButtonId, GestureDirection, default_binding_for};
19use crate::device::{Capabilities, DeviceKind, DeviceModelInfo};
20use crate::paths::{self, PathsError};
21
22pub const SCHEMA_VERSION: u32 = 3;
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Config {
40 pub schema_version: u32,
41 #[serde(default, skip_serializing_if = "AppSettings::is_default")]
43 pub app_settings: AppSettings,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub selected_device: Option<String>,
49 #[serde(default)]
50 pub devices: BTreeMap<String, DeviceConfig>,
51}
52
53impl Default for Config {
54 fn default() -> Self {
55 Self {
56 schema_version: SCHEMA_VERSION,
57 app_settings: AppSettings::default(),
58 selected_device: None,
59 devices: BTreeMap::new(),
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum Appearance {
71 #[default]
73 System,
74 Light,
76 Dark,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[allow(
86 clippy::struct_excessive_bools,
87 reason = "independent on/off user preferences, not a state machine"
88)]
89pub struct AppSettings {
90 #[serde(default)]
96 pub launch_at_login: bool,
97 #[serde(default)]
103 pub check_for_updates: bool,
104 #[serde(default)]
111 pub auto_install_updates: bool,
112 #[serde(default)]
117 pub update_prompt_seen: bool,
118 #[serde(default = "default_true")]
124 pub show_in_menu_bar: bool,
125 #[serde(default = "default_true")]
131 pub auto_download_assets: bool,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub language: Option<String>,
139 #[serde(default = "default_thumbwheel_sensitivity")]
146 pub thumbwheel_sensitivity: i32,
147 #[serde(default)]
149 pub appearance: Appearance,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub theme_light: Option<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub theme_dark: Option<String>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub ui_radius: Option<u8>,
163}
164
165pub const DEFAULT_THUMBWHEEL_SENSITIVITY: i32 = 14;
169pub const MIN_THUMBWHEEL_SENSITIVITY: i32 = 1;
171pub const MAX_THUMBWHEEL_SENSITIVITY: i32 = 100;
173
174impl AppSettings {
175 #[must_use]
178 pub fn is_default(&self) -> bool {
179 self == &Self::default()
180 }
181}
182
183impl Default for AppSettings {
184 fn default() -> Self {
185 Self {
186 launch_at_login: false,
187 check_for_updates: false,
188 auto_install_updates: false,
189 update_prompt_seen: false,
190 show_in_menu_bar: true,
191 auto_download_assets: true,
192 language: None,
193 thumbwheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY,
194 appearance: Appearance::System,
195 theme_light: None,
196 theme_dark: None,
197 ui_radius: None,
198 }
199 }
200}
201
202fn default_true() -> bool {
205 true
206}
207
208const fn default_thumbwheel_sensitivity() -> i32 {
211 DEFAULT_THUMBWHEEL_SENSITIVITY
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct Lighting {
222 #[serde(default = "default_lighting_enabled")]
223 pub enabled: bool,
224 #[serde(default = "default_lighting_color")]
226 pub color: String,
227 #[serde(
229 default = "default_lighting_brightness",
230 deserialize_with = "deserialize_brightness"
231 )]
232 pub brightness: u8,
233}
234
235impl Default for Lighting {
236 fn default() -> Self {
237 Self {
238 enabled: default_lighting_enabled(),
239 color: default_lighting_color(),
240 brightness: default_lighting_brightness(),
241 }
242 }
243}
244
245fn default_lighting_enabled() -> bool {
246 true
247}
248
249fn default_lighting_color() -> String {
250 "ffffff".to_string()
251}
252
253fn default_lighting_brightness() -> u8 {
254 100
255}
256
257fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
261where
262 D: serde::Deserializer<'de>,
263{
264 Ok(u8::deserialize(deserializer)?.min(100))
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "snake_case")]
270pub enum WheelMode {
271 Free,
272 Ratchet,
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
284pub struct SmartShift {
285 pub mode: WheelMode,
286 pub auto_disengage: u8,
289 pub tunable_torque: u8,
292}
293
294#[derive(Clone, Copy, Debug, PartialEq, Eq)]
302pub enum GestureOwner {
303 Off,
305 Button(ButtonId),
307}
308
309impl Serialize for GestureOwner {
310 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
311 match self {
312 GestureOwner::Off => serializer.serialize_str("Off"),
315 GestureOwner::Button(id) => id.serialize(serializer),
316 }
317 }
318}
319
320fn deserialize_gesture_owner<'de, D>(deserializer: D) -> Result<Option<GestureOwner>, D::Error>
327where
328 D: serde::Deserializer<'de>,
329{
330 let s = String::deserialize(deserializer)?;
331 if s == "Off" {
332 return Ok(Some(GestureOwner::Off));
333 }
334 let button = ButtonId::deserialize(
337 serde::de::value::StrDeserializer::<serde::de::value::Error>::new(&s),
338 )
339 .ok();
340 Ok(button.map(GestureOwner::Button))
341}
342
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356pub struct DeviceIdentity {
357 pub display_name: String,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub model_info: Option<DeviceModelInfo>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub codename: Option<String>,
368 pub kind: DeviceKind,
371 pub capabilities: Capabilities,
374}
375
376#[derive(Debug, Clone, Default, Serialize, Deserialize)]
384#[serde(from = "RawDeviceConfig")]
385pub struct DeviceConfig {
386 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub gesture_owner: Option<GestureOwner>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub identity: Option<DeviceIdentity>,
398 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
402 pub bindings: BTreeMap<ButtonId, Binding>,
403 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
410 pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
411 #[serde(default, skip_serializing_if = "Vec::is_empty")]
416 pub dpi_presets: Vec<u32>,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub dpi: Option<u32>,
423 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub lighting: Option<Lighting>,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub smartshift: Option<SmartShift>,
431 #[serde(default, skip_serializing_if = "is_false")]
438 pub invert_scroll: bool,
439}
440
441#[allow(
444 clippy::trivially_copy_pass_by_ref,
445 reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
446)]
447fn is_false(b: &bool) -> bool {
448 !*b
449}
450
451#[derive(Deserialize)]
456struct RawDeviceConfig {
457 #[serde(default, deserialize_with = "deserialize_gesture_owner")]
462 gesture_owner: Option<GestureOwner>,
463 #[serde(default)]
464 identity: Option<DeviceIdentity>,
465 #[serde(default)]
467 bindings: BTreeMap<ButtonId, Binding>,
468 #[serde(default)]
470 button_bindings: BTreeMap<ButtonId, Action>,
471 #[serde(default)]
473 gesture_bindings: BTreeMap<GestureDirection, Action>,
474 #[serde(default)]
475 per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
476 #[serde(default)]
477 dpi_presets: Vec<u32>,
478 #[serde(default)]
479 dpi: Option<u32>,
480 #[serde(default)]
481 lighting: Option<Lighting>,
482 #[serde(default)]
483 smartshift: Option<SmartShift>,
484 #[serde(default)]
485 invert_scroll: bool,
486}
487
488impl From<RawDeviceConfig> for DeviceConfig {
489 fn from(raw: RawDeviceConfig) -> Self {
490 let mut bindings = raw.bindings; if !raw.gesture_bindings.is_empty() {
498 bindings
499 .entry(ButtonId::GestureButton)
500 .or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
501 }
502 for (button, action) in raw.button_bindings {
503 if button == ButtonId::GestureButton {
514 continue;
515 }
516 bindings.entry(button).or_insert(Binding::Single(action));
517 }
518
519 DeviceConfig {
520 gesture_owner: raw.gesture_owner,
521 identity: raw.identity,
522 bindings,
523 per_app_bindings: raw.per_app_bindings,
524 dpi_presets: raw.dpi_presets,
525 dpi: raw.dpi,
526 lighting: raw.lighting,
527 smartshift: raw.smartshift,
528 invert_scroll: raw.invert_scroll,
529 }
530 }
531}
532
533#[derive(Debug, Error)]
534pub enum ConfigError {
535 #[error("could not resolve config path")]
536 Path(#[from] PathsError),
537 #[error("could not read config at {path}")]
538 Read {
539 path: PathBuf,
540 #[source]
541 source: io::Error,
542 },
543 #[error("could not parse config at {path}")]
544 Parse {
545 path: PathBuf,
546 #[source]
547 source: toml::de::Error,
548 },
549 #[error("could not write config at {path}")]
550 Write {
551 path: PathBuf,
552 #[source]
553 source: io::Error,
554 },
555 #[error("could not serialize config")]
556 Serialize(#[from] toml::ser::Error),
557 #[error("config at {path} has unsupported schema_version {found}")]
558 UnsupportedSchemaVersion { path: PathBuf, found: u32 },
559}
560
561#[allow(
562 clippy::result_large_err,
563 reason = "Config I/O keeps rich parse/write context and is not a hot path"
564)]
565impl Config {
566 pub fn load_or_default() -> Result<Self, ConfigError> {
569 Self::load_from_path(&paths::config_path()?)
570 }
571
572 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
575 match fs::read_to_string(path) {
576 Ok(text) => {
577 let mut config: Self =
578 toml::from_str(&text).map_err(|source| ConfigError::Parse {
579 path: path.to_path_buf(),
580 source,
581 })?;
582 if config.schema_version > SCHEMA_VERSION {
588 return Err(ConfigError::UnsupportedSchemaVersion {
589 path: path.to_path_buf(),
590 found: config.schema_version,
591 });
592 }
593 config.schema_version = SCHEMA_VERSION;
597 Ok(config)
598 }
599 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
600 Err(source) => Err(ConfigError::Read {
601 path: path.to_path_buf(),
602 source,
603 }),
604 }
605 }
606
607 pub fn save_atomic(&self) -> Result<(), ConfigError> {
611 self.save_to_path(&paths::config_path()?)
612 }
613
614 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
616 if let Some(parent) = path.parent() {
617 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
618 path: path.to_path_buf(),
619 source,
620 })?;
621 }
622 let body = toml::to_string_pretty(self)?;
623 write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
624 path: path.to_path_buf(),
625 source,
626 })
627 }
628
629 #[must_use]
632 pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
633 self.devices
634 .get(device_key)
635 .map(|d| d.bindings.clone())
636 .unwrap_or_default()
637 }
638
639 pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
644 self.devices
645 .entry(device_key.to_string())
646 .or_default()
647 .bindings
648 .insert(button, binding);
649 }
650
651 #[must_use]
656 pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
657 match self
658 .devices
659 .get(device_key)
660 .and_then(|d| d.bindings.get(&ButtonId::GestureButton))
661 {
662 Some(Binding::Gesture(map)) => map.clone(),
663 _ => BTreeMap::new(),
664 }
665 }
666
667 pub fn set_gesture_direction(
677 &mut self,
678 device_key: &str,
679 button: ButtonId,
680 direction: GestureDirection,
681 action: Action,
682 ) {
683 if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
684 map.insert(direction, action);
685 }
686 }
687
688 fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
696 let entry = self
697 .devices
698 .entry(device_key.to_string())
699 .or_default()
700 .bindings
701 .entry(button)
702 .or_insert_with(|| default_binding_for(button));
703 entry.upgrade_to_gesture();
704 entry
705 }
706
707 #[must_use]
716 pub fn gesture_owner(&self, device_key: &str) -> Option<ButtonId> {
717 let Some(device) = self.devices.get(device_key) else {
718 return Some(ButtonId::GestureButton);
720 };
721 match device.gesture_owner {
722 Some(GestureOwner::Off) => None,
723 Some(GestureOwner::Button(id)) => Some(id),
724 None => Self::infer_gesture_owner(&device.bindings),
725 }
726 }
727
728 fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
733 if let Some((id, _)) = bindings
735 .iter()
736 .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
737 {
738 return Some(*id);
739 }
740 if matches!(
742 bindings.get(&ButtonId::GestureButton),
743 Some(Binding::Single(_))
744 ) {
745 return None;
746 }
747 Some(ButtonId::GestureButton)
749 }
750
751 pub fn set_gesture_owner(&mut self, device_key: &str, button: ButtonId) {
764 self.devices
765 .entry(device_key.to_string())
766 .or_default()
767 .gesture_owner = Some(GestureOwner::Button(button));
768 self.ensure_gesture_binding(device_key, button)
769 .fill_gesture_defaults();
770 }
771
772 pub fn disable_gestures(&mut self, device_key: &str) {
776 self.devices
777 .entry(device_key.to_string())
778 .or_default()
779 .gesture_owner = Some(GestureOwner::Off);
780 }
781
782 #[must_use]
790 pub fn effective_bindings(
791 &self,
792 device_key: &str,
793 bundle_id: Option<&str>,
794 ) -> BTreeMap<ButtonId, Binding> {
795 let Some(device) = self.devices.get(device_key) else {
796 return BTreeMap::new();
797 };
798 let mut out = device.bindings.clone();
799 if let Some(bid) = bundle_id
800 && let Some(overlay) = device.per_app_bindings.get(bid)
801 {
802 for (k, v) in overlay {
803 out.insert(*k, Binding::Single(v.clone()));
804 }
805 }
806 out
807 }
808
809 pub fn set_per_app_binding(
813 &mut self,
814 device_key: &str,
815 bundle_id: &str,
816 button: ButtonId,
817 action: Option<Action>,
818 ) {
819 let entry = self
820 .devices
821 .entry(device_key.to_string())
822 .or_default()
823 .per_app_bindings
824 .entry(bundle_id.to_string())
825 .or_default();
826 match action {
827 Some(a) => {
828 entry.insert(button, a);
829 }
830 None => {
831 entry.remove(&button);
832 }
833 }
834 if let Some(d) = self.devices.get_mut(device_key) {
835 d.per_app_bindings.retain(|_, m| !m.is_empty());
836 }
837 }
838
839 #[must_use]
841 pub fn selected_device(&self) -> Option<&str> {
842 self.selected_device.as_deref()
843 }
844
845 pub fn set_selected_device(&mut self, key: Option<String>) {
848 self.selected_device = key;
849 }
850
851 #[must_use]
854 pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
855 self.devices
856 .get(device_key)
857 .map(|d| d.dpi_presets.clone())
858 .unwrap_or_default()
859 }
860
861 pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
865 self.devices
866 .entry(device_key.to_string())
867 .or_default()
868 .dpi_presets = presets;
869 }
870
871 #[must_use]
875 pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
876 self.devices
877 .get(device_key)
878 .and_then(|d| d.identity.as_ref())
879 }
880
881 pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
884 self.devices
885 .entry(device_key.to_string())
886 .or_default()
887 .identity = Some(identity);
888 }
889
890 #[must_use]
895 pub fn has_app_override(&self, device_key: &str, app: &str) -> bool {
896 self.devices.get(device_key).is_some_and(|d| {
897 d.per_app_bindings
898 .get(app)
899 .is_some_and(|overlay| !overlay.is_empty())
900 })
901 }
902
903 pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
907 self.devices
908 .iter()
909 .filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
910 }
911
912 #[must_use]
914 pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
915 self.devices
916 .get(device_key)
917 .and_then(|d| d.lighting.clone())
918 }
919
920 pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
922 self.devices
923 .entry(device_key.to_string())
924 .or_default()
925 .lighting = Some(lighting);
926 }
927
928 #[must_use]
930 pub fn dpi(&self, device_key: &str) -> Option<u32> {
931 self.devices.get(device_key).and_then(|d| d.dpi)
932 }
933
934 pub fn set_dpi(&mut self, device_key: &str, dpi: u32) {
937 self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
938 }
939
940 #[must_use]
942 pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
943 self.devices.get(device_key).and_then(|d| d.smartshift)
944 }
945
946 pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
949 self.devices
950 .entry(device_key.to_string())
951 .or_default()
952 .smartshift = Some(smartshift);
953 }
954
955 #[must_use]
958 pub fn invert_scroll(&self, device_key: &str) -> bool {
959 self.devices
960 .get(device_key)
961 .is_some_and(|d| d.invert_scroll)
962 }
963
964 pub fn set_invert_scroll(&mut self, device_key: &str, invert: bool) {
967 self.devices
968 .entry(device_key.to_string())
969 .or_default()
970 .invert_scroll = invert;
971 }
972}
973
974fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
975 let tmp = path.with_extension("toml.tmp");
976 {
977 #[cfg(unix)]
978 {
979 use std::os::unix::fs::OpenOptionsExt;
980 let mut f = fs::OpenOptions::new()
981 .write(true)
982 .create(true)
983 .truncate(true)
984 .mode(0o600)
985 .open(&tmp)?;
986 io::Write::write_all(&mut f, bytes)?;
987 f.sync_all()?;
988 }
989 #[cfg(not(unix))]
990 {
991 let mut f = fs::OpenOptions::new()
992 .write(true)
993 .create(true)
994 .truncate(true)
995 .open(&tmp)?;
996 io::Write::write_all(&mut f, bytes)?;
997 f.sync_all()?;
998 }
999 }
1000 fs::rename(&tmp, path)
1001}
1002
1003#[cfg(test)]
1004#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
1005mod tests {
1006 use super::*;
1007 use crate::binding::{default_binding, default_gesture_binding};
1008
1009 fn write_and_read(config: &Config) -> Config {
1010 let dir = tempfile::tempdir().expect("tempdir");
1011 let path = dir.path().join("config.toml");
1012 config.save_to_path(&path).expect("save");
1013 Config::load_from_path(&path).expect("load")
1014 }
1015
1016 #[test]
1017 fn missing_file_yields_default() {
1018 let dir = tempfile::tempdir().expect("tempdir");
1019 let path = dir.path().join("nonexistent.toml");
1020 let cfg = Config::load_from_path(&path).expect("load");
1021 assert_eq!(cfg.schema_version, SCHEMA_VERSION);
1022 assert!(cfg.devices.is_empty());
1023 }
1024
1025 #[test]
1026 fn lighting_roundtrips_per_device() {
1027 let mut cfg = Config::default();
1028 cfg.set_lighting(
1029 "g513",
1030 Lighting {
1031 enabled: true,
1032 color: "00aabb".to_string(),
1033 brightness: 75,
1034 },
1035 );
1036 let restored = write_and_read(&cfg);
1037 assert_eq!(
1038 restored.lighting("g513"),
1039 Some(Lighting {
1040 enabled: true,
1041 color: "00aabb".to_string(),
1042 brightness: 75,
1043 })
1044 );
1045 assert_eq!(restored.lighting("absent"), None);
1046 }
1047
1048 #[test]
1049 fn dpi_roundtrips_per_device() {
1050 let mut cfg = Config::default();
1051 cfg.set_dpi("2b042", 1600);
1052 let restored = write_and_read(&cfg);
1053 assert_eq!(restored.dpi("2b042"), Some(1600));
1054 assert_eq!(restored.dpi("absent"), None);
1055 }
1056
1057 #[test]
1058 fn smartshift_roundtrips_per_device() {
1059 let mut cfg = Config::default();
1060 cfg.set_smartshift(
1061 "2b042",
1062 SmartShift {
1063 mode: WheelMode::Ratchet,
1064 auto_disengage: 16,
1065 tunable_torque: 30,
1066 },
1067 );
1068 let restored = write_and_read(&cfg);
1069 assert_eq!(
1070 restored.smartshift("2b042"),
1071 Some(SmartShift {
1072 mode: WheelMode::Ratchet,
1073 auto_disengage: 16,
1074 tunable_torque: 30,
1075 })
1076 );
1077 assert_eq!(restored.smartshift("absent"), None);
1078 }
1079
1080 #[test]
1081 fn invert_scroll_roundtrips_per_device() {
1082 let mut cfg = Config::default();
1083 assert!(!cfg.invert_scroll("2b042"));
1085 cfg.set_invert_scroll("2b042", true);
1086 let restored = write_and_read(&cfg);
1087 assert!(restored.invert_scroll("2b042"));
1088 assert!(!restored.invert_scroll("absent"));
1089 }
1090
1091 #[test]
1092 fn default_invert_scroll_is_omitted_from_toml() {
1093 let mut cfg = Config::default();
1096 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1097 cfg.set_invert_scroll("2b042", false);
1098 let body = toml::to_string_pretty(&cfg).expect("serialize");
1099 assert!(
1100 !body.contains("invert_scroll"),
1101 "default invert_scroll should be omitted: {body}"
1102 );
1103 }
1104
1105 #[test]
1106 fn bindings_roundtrip_per_device() {
1107 let mut cfg = Config::default();
1108 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1109 cfg.set_binding(
1110 "2b042",
1111 ButtonId::DpiToggle,
1112 Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
1113 modifiers: crate::binding::KeyCombo::MOD_CMD,
1114 key_code: 0x23, display: "⌘P".into(),
1116 })),
1117 );
1118 cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
1119
1120 let parsed = write_and_read(&cfg);
1121
1122 let a = parsed.bindings_for("2b042");
1124 assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
1125 assert_eq!(
1126 a.get(&ButtonId::DpiToggle),
1127 Some(&Binding::Single(Action::CustomShortcut(
1128 crate::binding::KeyCombo {
1129 modifiers: crate::binding::KeyCombo::MOD_CMD,
1130 key_code: 0x23,
1131 display: "⌘P".into(),
1132 }
1133 )))
1134 );
1135
1136 let b = parsed.bindings_for("4082d");
1137 assert_eq!(
1138 b.get(&ButtonId::Back),
1139 Some(&Binding::Single(Action::Paste))
1140 );
1141 assert_eq!(b.len(), 1, "device b should only see its own bindings");
1142
1143 assert!(parsed.bindings_for("deadbeef").is_empty());
1145 }
1146
1147 #[test]
1148 fn human_readable_toml_layout() {
1149 let mut cfg = Config::default();
1150 cfg.set_binding(
1151 "2b042",
1152 ButtonId::Back,
1153 Binding::Single(Action::BrowserBack),
1154 );
1155 let body = toml::to_string_pretty(&cfg).expect("serialize");
1156
1157 assert!(body.contains("schema_version = 3"), "got: {body}");
1161 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1162 assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
1165 }
1166
1167 #[test]
1168 fn dpi_presets_roundtrip_per_device() {
1169 let mut cfg = Config::default();
1170 cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
1171 cfg.set_dpi_presets("4082d", vec![400, 1600]);
1172
1173 let parsed = write_and_read(&cfg);
1174
1175 assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
1176 assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
1177 assert!(parsed.dpi_presets("unknown").is_empty());
1178 }
1179
1180 #[test]
1181 fn empty_dpi_presets_skip_serialization() {
1182 let mut cfg = Config::default();
1183 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1185 cfg.set_dpi_presets("2b042", vec![800]);
1186 cfg.set_dpi_presets("2b042", vec![]); let body = toml::to_string_pretty(&cfg).expect("serialize");
1189 assert!(
1190 !body.contains("dpi_presets"),
1191 "empty dpi_presets should be omitted: {body}"
1192 );
1193 }
1194
1195 #[test]
1196 fn device_identity_roundtrips_and_is_iterable() {
1197 use crate::device::{Capabilities, DeviceKind};
1198
1199 let mut cfg = Config::default();
1200 let mouse = DeviceIdentity {
1201 display_name: "MX Master 3S".to_string(),
1202 model_info: None,
1203 codename: None,
1204 kind: DeviceKind::Mouse,
1205 capabilities: Capabilities {
1206 buttons: true,
1207 pointer: true,
1208 lighting: false,
1209 scroll_inversion: false,
1210 },
1211 };
1212 cfg.set_device_identity("2b034", mouse.clone());
1213 cfg.set_binding(
1215 "2b034",
1216 ButtonId::Back,
1217 Binding::Single(Action::BrowserBack),
1218 );
1219
1220 let parsed = write_and_read(&cfg);
1221 assert_eq!(parsed.device_identity("2b034"), Some(&mouse));
1222 assert_eq!(parsed.device_identity("absent"), None);
1223 assert_eq!(
1224 parsed.bindings_for("2b034").get(&ButtonId::Back),
1225 Some(&Binding::Single(Action::BrowserBack)),
1226 "identity must coexist with bindings on the same device block"
1227 );
1228 assert_eq!(
1229 parsed.known_identities().collect::<Vec<_>>(),
1230 vec![("2b034", &mouse)]
1231 );
1232 }
1233
1234 #[test]
1235 fn selected_device_roundtrips() {
1236 let mut cfg = Config::default();
1237 assert_eq!(cfg.selected_device(), None);
1238 cfg.set_selected_device(Some("2b042".into()));
1239 let parsed = write_and_read(&cfg);
1240 assert_eq!(parsed.selected_device(), Some("2b042"));
1241 }
1242
1243 #[test]
1244 fn per_app_overlay_takes_precedence() {
1245 let mut cfg = Config::default();
1246 cfg.set_binding(
1247 "2b042",
1248 ButtonId::Back,
1249 Binding::Single(Action::BrowserBack),
1250 );
1251 cfg.set_binding(
1252 "2b042",
1253 ButtonId::Forward,
1254 Binding::Single(Action::BrowserForward),
1255 );
1256 cfg.set_per_app_binding(
1257 "2b042",
1258 "com.microsoft.VSCode",
1259 ButtonId::Back,
1260 Some(Action::Undo),
1261 );
1262
1263 let global = cfg.effective_bindings("2b042", None);
1265 assert_eq!(
1266 global.get(&ButtonId::Back),
1267 Some(&Binding::Single(Action::BrowserBack))
1268 );
1269 assert_eq!(
1270 global.get(&ButtonId::Forward),
1271 Some(&Binding::Single(Action::BrowserForward))
1272 );
1273
1274 let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
1276 assert_eq!(
1277 vscode.get(&ButtonId::Back),
1278 Some(&Binding::Single(Action::Undo))
1279 );
1280 assert_eq!(
1281 vscode.get(&ButtonId::Forward),
1282 Some(&Binding::Single(Action::BrowserForward))
1283 );
1284
1285 let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
1287 assert_eq!(
1288 other.get(&ButtonId::Back),
1289 Some(&Binding::Single(Action::BrowserBack))
1290 );
1291 }
1292
1293 #[test]
1294 fn per_app_binding_removal_prunes_empty_app() {
1295 let mut cfg = Config::default();
1296 cfg.set_per_app_binding(
1297 "2b042",
1298 "com.example.App",
1299 ButtonId::Back,
1300 Some(Action::Copy),
1301 );
1302 cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
1303 assert!(
1304 cfg.devices["2b042"].per_app_bindings.is_empty(),
1305 "removing last override should prune the app entry"
1306 );
1307 }
1308
1309 #[test]
1310 fn app_settings_default_omits_block() {
1311 let cfg = Config::default();
1312 let body = toml::to_string_pretty(&cfg).expect("serialize");
1313 assert!(
1314 !body.contains("app_settings"),
1315 "default app_settings should be omitted: {body}"
1316 );
1317 }
1318
1319 #[test]
1320 fn app_settings_launch_at_login_roundtrips() {
1321 let mut cfg = Config::default();
1322 cfg.app_settings.launch_at_login = true;
1323 let parsed = write_and_read(&cfg);
1324 assert!(parsed.app_settings.launch_at_login);
1325 }
1326
1327 #[test]
1328 fn cleared_selected_device_omits_field() {
1329 let mut cfg = Config::default();
1330 cfg.set_selected_device(Some("2b042".into()));
1331 cfg.set_selected_device(None);
1332 let body = toml::to_string_pretty(&cfg).expect("serialize");
1333 assert!(
1334 !body.contains("selected_device"),
1335 "cleared selection should not appear: {body}"
1336 );
1337 }
1338
1339 #[test]
1340 fn empty_device_block_is_skipped_in_output() {
1341 let mut cfg = Config::default();
1344 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1345 cfg.devices
1346 .get_mut("2b042")
1347 .expect("entry")
1348 .bindings
1349 .clear();
1350 let body = toml::to_string_pretty(&cfg).expect("serialize");
1351 assert!(
1352 !body.contains("Back"),
1353 "cleared bindings should not appear: {body}"
1354 );
1355 }
1356
1357 #[test]
1358 fn migrates_v1_button_and_gesture_bindings() {
1359 let v1 = "\
1361schema_version = 1
1362
1363[devices.2b042.button_bindings]
1364Back = \"BrowserBack\"
1365
1366[devices.2b042.gesture_bindings]
1367Up = \"Copy\"
1368Click = \"Paste\"
1369";
1370 let dir = tempfile::tempdir().expect("tempdir");
1371 let path = dir.path().join("config.toml");
1372 fs::write(&path, v1).expect("write");
1373
1374 let cfg = Config::load_from_path(&path).expect("load v1");
1376 let bindings = cfg.bindings_for("2b042");
1377 assert_eq!(
1378 bindings.get(&ButtonId::Back),
1379 Some(&Binding::Single(Action::BrowserBack))
1380 );
1381 let mut gesture = BTreeMap::new();
1382 gesture.insert(GestureDirection::Up, Action::Copy);
1383 gesture.insert(GestureDirection::Click, Action::Paste);
1384 assert_eq!(
1385 bindings.get(&ButtonId::GestureButton),
1386 Some(&Binding::Gesture(gesture))
1387 );
1388
1389 let body = toml::to_string_pretty(&cfg).expect("serialize");
1392 assert!(body.contains("schema_version = 3"), "got: {body}");
1393 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1394 assert!(!body.contains("button_bindings"), "got: {body}");
1395 assert!(!body.contains("gesture_bindings"), "got: {body}");
1396 }
1397
1398 #[test]
1399 fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
1400 let v1 = "\
1405schema_version = 1
1406
1407[devices.2b042.button_bindings]
1408GestureButton = \"MissionControl\"
1409
1410[devices.2b042.gesture_bindings]
1411Up = \"Copy\"
1412Down = \"Paste\"
1413";
1414 let dir = tempfile::tempdir().expect("tempdir");
1415 let path = dir.path().join("config.toml");
1416 fs::write(&path, v1).expect("write");
1417
1418 let cfg = Config::load_from_path(&path).expect("load v1");
1419 let mut gesture = BTreeMap::new();
1420 gesture.insert(GestureDirection::Up, Action::Copy);
1421 gesture.insert(GestureDirection::Down, Action::Paste);
1422 assert_eq!(
1423 cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
1424 Some(&Binding::Gesture(gesture)),
1425 "gesture map must win over the legacy single GestureButton entry"
1426 );
1427 }
1428
1429 #[test]
1430 fn migration_drops_vestigial_lone_gesture_button_single() {
1431 let v1 = "\
1438schema_version = 1
1439
1440[devices.2b042.button_bindings]
1441GestureButton = \"MissionControl\"
1442Back = \"BrowserBack\"
1443";
1444 let dir = tempfile::tempdir().expect("tempdir");
1445 let path = dir.path().join("config.toml");
1446 fs::write(&path, v1).expect("write");
1447
1448 let bindings = Config::load_from_path(&path)
1449 .expect("load v1")
1450 .bindings_for("2b042");
1451 assert_eq!(
1453 bindings.get(&ButtonId::Back),
1454 Some(&Binding::Single(Action::BrowserBack))
1455 );
1456 assert_eq!(bindings.get(&ButtonId::GestureButton), None);
1459 }
1460
1461 #[test]
1462 fn rejects_newer_schema_version_but_accepts_v1() {
1463 let dir = tempfile::tempdir().expect("tempdir");
1466 let path = dir.path().join("config.toml");
1467 fs::write(&path, "schema_version = 99\n").expect("write");
1468 assert!(matches!(
1469 Config::load_from_path(&path).expect_err("v99 should fail"),
1470 ConfigError::UnsupportedSchemaVersion { found: 99, .. }
1471 ));
1472
1473 fs::write(&path, "schema_version = 1\n").expect("write");
1474 assert!(
1475 Config::load_from_path(&path).is_ok(),
1476 "v1 should still load"
1477 );
1478 }
1479
1480 #[test]
1481 fn set_gesture_direction_upgrades_single_to_gesture() {
1482 let mut cfg = Config::default();
1483 cfg.set_binding(
1485 "2b042",
1486 ButtonId::Back,
1487 Binding::Single(Action::BrowserBack),
1488 );
1489 cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
1490
1491 match cfg.bindings_for("2b042").get(&ButtonId::Back) {
1492 Some(Binding::Gesture(map)) => {
1493 assert_eq!(
1495 map.get(&GestureDirection::Click),
1496 Some(&Action::BrowserBack)
1497 );
1498 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1499 }
1500 other => panic!("expected Gesture after upgrade, got {other:?}"),
1501 }
1502 }
1503
1504 #[test]
1505 fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
1506 let mut cfg = Config::default();
1510 cfg.set_gesture_direction(
1511 "2b042",
1512 ButtonId::GestureButton,
1513 GestureDirection::Up,
1514 Action::Copy,
1515 );
1516
1517 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1518 Some(Binding::Gesture(map)) => {
1519 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1520 assert_eq!(
1521 map.get(&GestureDirection::Click),
1522 Some(&crate::binding::default_gesture_binding(
1523 GestureDirection::Click
1524 )),
1525 "a fresh gesture button must seed a Click from its default"
1526 );
1527 }
1528 other => panic!("expected Gesture, got {other:?}"),
1529 }
1530 }
1531
1532 #[test]
1533 fn gesture_owner_defaults_to_hidpp_button_yields_to_oshook_and_can_be_off() {
1534 let mut cfg = Config::default();
1535 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1537
1538 cfg.set_gesture_direction(
1540 "2b042",
1541 ButtonId::GestureButton,
1542 GestureDirection::Up,
1543 Action::MissionControl,
1544 );
1545 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1546
1547 cfg.set_binding(
1549 "2b042",
1550 ButtonId::Forward,
1551 Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
1552 );
1553 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
1554
1555 let mut off = Config::default();
1557 off.disable_gestures("2b042");
1558 assert_eq!(off.gesture_owner("2b042"), None);
1559 }
1560
1561 #[test]
1562 fn set_gesture_owner_records_owner_without_destroying_other_maps() {
1563 let mut cfg = Config::default();
1564 cfg.set_gesture_direction(
1566 "2b042",
1567 ButtonId::GestureButton,
1568 GestureDirection::Up,
1569 Action::Copy,
1570 );
1571 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1572
1573 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
1576 cfg.set_gesture_owner("2b042", ButtonId::Back);
1577 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
1578
1579 let bindings = cfg.bindings_for("2b042");
1580 match bindings.get(&ButtonId::Back) {
1583 Some(Binding::Gesture(map)) => {
1584 assert_eq!(
1585 map.get(&GestureDirection::Click),
1586 Some(&Action::BrowserBack)
1587 );
1588 assert_eq!(
1589 map.get(&GestureDirection::Up),
1590 Some(&default_gesture_binding(GestureDirection::Up)),
1591 "a promoted button gets full default arms"
1592 );
1593 }
1594 other => panic!("expected Back to be a gesture binding, got {other:?}"),
1595 }
1596 match bindings.get(&ButtonId::GestureButton) {
1598 Some(Binding::Gesture(map)) => {
1599 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1600 }
1601 other => panic!("expected the HID++ gesture button map preserved, got {other:?}"),
1602 }
1603
1604 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1607 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1608 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1609 Some(Binding::Gesture(map)) => {
1610 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1611 }
1612 other => panic!("expected preserved gesture map, got {other:?}"),
1613 }
1614 }
1615
1616 #[test]
1617 fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
1618 let mut cfg = Config::default();
1619 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1621 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1622 Some(Binding::Gesture(map)) => {
1623 for dir in GestureDirection::ALL {
1624 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1625 }
1626 }
1627 other => panic!("expected full default gesture map, got {other:?}"),
1628 }
1629
1630 cfg.set_gesture_owner("2b042", ButtonId::Forward);
1634 match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
1635 Some(Binding::Gesture(map)) => {
1636 assert_eq!(
1637 map.get(&GestureDirection::Click),
1638 Some(&default_binding(ButtonId::Forward))
1639 );
1640 for dir in [
1641 GestureDirection::Up,
1642 GestureDirection::Down,
1643 GestureDirection::Left,
1644 GestureDirection::Right,
1645 ] {
1646 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1647 }
1648 }
1649 other => panic!("expected full gesture map for Forward, got {other:?}"),
1650 }
1651 }
1652
1653 #[test]
1654 fn disable_gestures_turns_off_without_destroying_maps() {
1655 let mut cfg = Config::default();
1656 cfg.set_gesture_direction(
1657 "2b042",
1658 ButtonId::GestureButton,
1659 GestureDirection::Up,
1660 Action::Copy,
1661 );
1662 cfg.disable_gestures("2b042");
1663 assert_eq!(cfg.gesture_owner("2b042"), None);
1666 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1667 Some(Binding::Gesture(map)) => {
1668 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1669 }
1670 other => panic!("expected the gesture map preserved while off, got {other:?}"),
1671 }
1672 }
1673
1674 #[test]
1675 fn gesture_owner_field_roundtrips_as_a_scalar() {
1676 let mut cfg = Config::default();
1677 cfg.set_gesture_owner("2b042", ButtonId::Back); cfg.disable_gestures("4082d"); let parsed = write_and_read(&cfg);
1681 assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
1682 assert_eq!(parsed.gesture_owner("4082d"), None);
1683
1684 let body = toml::to_string_pretty(&cfg).expect("serialize");
1687 assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
1688 assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
1689 }
1690
1691 #[test]
1692 fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
1693 let toml = "\
1697schema_version = 2
1698
1699[devices.2b042]
1700gesture_owner = \"bogus\"
1701
1702[devices.2b042.bindings]
1703Back = \"Copy\"
1704";
1705 let dir = tempfile::tempdir().expect("tempdir");
1706 let path = dir.path().join("config.toml");
1707 fs::write(&path, toml).expect("write");
1708
1709 let cfg =
1710 Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
1711 assert_eq!(
1713 cfg.bindings_for("2b042").get(&ButtonId::Back),
1714 Some(&Binding::Single(Action::Copy))
1715 );
1716 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1718 }
1719}