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, PartialEq, Eq, Serialize, Deserialize)]
69#[allow(
70 clippy::struct_excessive_bools,
71 reason = "independent on/off user preferences, not a state machine"
72)]
73pub struct AppSettings {
74 #[serde(default)]
80 pub launch_at_login: bool,
81 #[serde(default)]
87 pub check_for_updates: bool,
88 #[serde(default)]
93 pub update_prompt_seen: bool,
94 #[serde(default = "default_true")]
99 pub show_in_menu_bar: bool,
100 #[serde(default = "default_true")]
106 pub auto_download_assets: bool,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub language: Option<String>,
114 #[serde(default = "default_thumbwheel_sensitivity")]
121 pub thumbwheel_sensitivity: i32,
122}
123
124pub const DEFAULT_THUMBWHEEL_SENSITIVITY: i32 = 14;
128pub const MIN_THUMBWHEEL_SENSITIVITY: i32 = 1;
130pub const MAX_THUMBWHEEL_SENSITIVITY: i32 = 100;
132
133impl AppSettings {
134 #[must_use]
137 pub fn is_default(&self) -> bool {
138 self == &Self::default()
139 }
140}
141
142impl Default for AppSettings {
143 fn default() -> Self {
144 Self {
145 launch_at_login: false,
146 check_for_updates: false,
147 update_prompt_seen: false,
148 show_in_menu_bar: true,
149 auto_download_assets: true,
150 language: None,
151 thumbwheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY,
152 }
153 }
154}
155
156fn default_true() -> bool {
159 true
160}
161
162const fn default_thumbwheel_sensitivity() -> i32 {
165 DEFAULT_THUMBWHEEL_SENSITIVITY
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct Lighting {
176 #[serde(default = "default_lighting_enabled")]
177 pub enabled: bool,
178 #[serde(default = "default_lighting_color")]
180 pub color: String,
181 #[serde(
183 default = "default_lighting_brightness",
184 deserialize_with = "deserialize_brightness"
185 )]
186 pub brightness: u8,
187}
188
189impl Default for Lighting {
190 fn default() -> Self {
191 Self {
192 enabled: default_lighting_enabled(),
193 color: default_lighting_color(),
194 brightness: default_lighting_brightness(),
195 }
196 }
197}
198
199fn default_lighting_enabled() -> bool {
200 true
201}
202
203fn default_lighting_color() -> String {
204 "ffffff".to_string()
205}
206
207fn default_lighting_brightness() -> u8 {
208 100
209}
210
211fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
215where
216 D: serde::Deserializer<'de>,
217{
218 Ok(u8::deserialize(deserializer)?.min(100))
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum WheelMode {
225 Free,
226 Ratchet,
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
238pub struct SmartShift {
239 pub mode: WheelMode,
240 pub auto_disengage: u8,
243 pub tunable_torque: u8,
246}
247
248#[derive(Clone, Copy, Debug, PartialEq, Eq)]
256pub enum GestureOwner {
257 Off,
259 Button(ButtonId),
261}
262
263impl Serialize for GestureOwner {
264 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
265 match self {
266 GestureOwner::Off => serializer.serialize_str("Off"),
269 GestureOwner::Button(id) => id.serialize(serializer),
270 }
271 }
272}
273
274fn deserialize_gesture_owner<'de, D>(deserializer: D) -> Result<Option<GestureOwner>, D::Error>
281where
282 D: serde::Deserializer<'de>,
283{
284 let s = String::deserialize(deserializer)?;
285 if s == "Off" {
286 return Ok(Some(GestureOwner::Off));
287 }
288 let button = ButtonId::deserialize(
291 serde::de::value::StrDeserializer::<serde::de::value::Error>::new(&s),
292 )
293 .ok();
294 Ok(button.map(GestureOwner::Button))
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310pub struct DeviceIdentity {
311 pub display_name: String,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub model_info: Option<DeviceModelInfo>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub codename: Option<String>,
322 pub kind: DeviceKind,
325 pub capabilities: Capabilities,
328}
329
330#[derive(Debug, Clone, Default, Serialize, Deserialize)]
338#[serde(from = "RawDeviceConfig")]
339pub struct DeviceConfig {
340 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub gesture_owner: Option<GestureOwner>,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub identity: Option<DeviceIdentity>,
352 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
356 pub bindings: BTreeMap<ButtonId, Binding>,
357 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
364 pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
365 #[serde(default, skip_serializing_if = "Vec::is_empty")]
370 pub dpi_presets: Vec<u32>,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub dpi: Option<u32>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub lighting: Option<Lighting>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub smartshift: Option<SmartShift>,
385 #[serde(default, skip_serializing_if = "is_false")]
392 pub invert_scroll: bool,
393}
394
395#[allow(
398 clippy::trivially_copy_pass_by_ref,
399 reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
400)]
401fn is_false(b: &bool) -> bool {
402 !*b
403}
404
405#[derive(Deserialize)]
410struct RawDeviceConfig {
411 #[serde(default, deserialize_with = "deserialize_gesture_owner")]
416 gesture_owner: Option<GestureOwner>,
417 #[serde(default)]
418 identity: Option<DeviceIdentity>,
419 #[serde(default)]
421 bindings: BTreeMap<ButtonId, Binding>,
422 #[serde(default)]
424 button_bindings: BTreeMap<ButtonId, Action>,
425 #[serde(default)]
427 gesture_bindings: BTreeMap<GestureDirection, Action>,
428 #[serde(default)]
429 per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
430 #[serde(default)]
431 dpi_presets: Vec<u32>,
432 #[serde(default)]
433 dpi: Option<u32>,
434 #[serde(default)]
435 lighting: Option<Lighting>,
436 #[serde(default)]
437 smartshift: Option<SmartShift>,
438 #[serde(default)]
439 invert_scroll: bool,
440}
441
442impl From<RawDeviceConfig> for DeviceConfig {
443 fn from(raw: RawDeviceConfig) -> Self {
444 let mut bindings = raw.bindings; if !raw.gesture_bindings.is_empty() {
452 bindings
453 .entry(ButtonId::GestureButton)
454 .or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
455 }
456 for (button, action) in raw.button_bindings {
457 if button == ButtonId::GestureButton {
468 continue;
469 }
470 bindings.entry(button).or_insert(Binding::Single(action));
471 }
472
473 DeviceConfig {
474 gesture_owner: raw.gesture_owner,
475 identity: raw.identity,
476 bindings,
477 per_app_bindings: raw.per_app_bindings,
478 dpi_presets: raw.dpi_presets,
479 dpi: raw.dpi,
480 lighting: raw.lighting,
481 smartshift: raw.smartshift,
482 invert_scroll: raw.invert_scroll,
483 }
484 }
485}
486
487#[derive(Debug, Error)]
488pub enum ConfigError {
489 #[error("could not resolve config path")]
490 Path(#[from] PathsError),
491 #[error("could not read config at {path}")]
492 Read {
493 path: PathBuf,
494 #[source]
495 source: io::Error,
496 },
497 #[error("could not parse config at {path}")]
498 Parse {
499 path: PathBuf,
500 #[source]
501 source: toml::de::Error,
502 },
503 #[error("could not write config at {path}")]
504 Write {
505 path: PathBuf,
506 #[source]
507 source: io::Error,
508 },
509 #[error("could not serialize config")]
510 Serialize(#[from] toml::ser::Error),
511 #[error("config at {path} has unsupported schema_version {found}")]
512 UnsupportedSchemaVersion { path: PathBuf, found: u32 },
513}
514
515#[allow(
516 clippy::result_large_err,
517 reason = "Config I/O keeps rich parse/write context and is not a hot path"
518)]
519impl Config {
520 pub fn load_or_default() -> Result<Self, ConfigError> {
523 Self::load_from_path(&paths::config_path()?)
524 }
525
526 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
529 match fs::read_to_string(path) {
530 Ok(text) => {
531 let mut config: Self =
532 toml::from_str(&text).map_err(|source| ConfigError::Parse {
533 path: path.to_path_buf(),
534 source,
535 })?;
536 if config.schema_version > SCHEMA_VERSION {
542 return Err(ConfigError::UnsupportedSchemaVersion {
543 path: path.to_path_buf(),
544 found: config.schema_version,
545 });
546 }
547 config.schema_version = SCHEMA_VERSION;
551 Ok(config)
552 }
553 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
554 Err(source) => Err(ConfigError::Read {
555 path: path.to_path_buf(),
556 source,
557 }),
558 }
559 }
560
561 pub fn save_atomic(&self) -> Result<(), ConfigError> {
565 self.save_to_path(&paths::config_path()?)
566 }
567
568 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
570 if let Some(parent) = path.parent() {
571 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
572 path: path.to_path_buf(),
573 source,
574 })?;
575 }
576 let body = toml::to_string_pretty(self)?;
577 write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
578 path: path.to_path_buf(),
579 source,
580 })
581 }
582
583 #[must_use]
586 pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
587 self.devices
588 .get(device_key)
589 .map(|d| d.bindings.clone())
590 .unwrap_or_default()
591 }
592
593 pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
598 self.devices
599 .entry(device_key.to_string())
600 .or_default()
601 .bindings
602 .insert(button, binding);
603 }
604
605 #[must_use]
610 pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
611 match self
612 .devices
613 .get(device_key)
614 .and_then(|d| d.bindings.get(&ButtonId::GestureButton))
615 {
616 Some(Binding::Gesture(map)) => map.clone(),
617 _ => BTreeMap::new(),
618 }
619 }
620
621 pub fn set_gesture_direction(
631 &mut self,
632 device_key: &str,
633 button: ButtonId,
634 direction: GestureDirection,
635 action: Action,
636 ) {
637 if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
638 map.insert(direction, action);
639 }
640 }
641
642 fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
650 let entry = self
651 .devices
652 .entry(device_key.to_string())
653 .or_default()
654 .bindings
655 .entry(button)
656 .or_insert_with(|| default_binding_for(button));
657 entry.upgrade_to_gesture();
658 entry
659 }
660
661 #[must_use]
670 pub fn gesture_owner(&self, device_key: &str) -> Option<ButtonId> {
671 let Some(device) = self.devices.get(device_key) else {
672 return Some(ButtonId::GestureButton);
674 };
675 match device.gesture_owner {
676 Some(GestureOwner::Off) => None,
677 Some(GestureOwner::Button(id)) => Some(id),
678 None => Self::infer_gesture_owner(&device.bindings),
679 }
680 }
681
682 fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
687 if let Some((id, _)) = bindings
689 .iter()
690 .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
691 {
692 return Some(*id);
693 }
694 if matches!(
696 bindings.get(&ButtonId::GestureButton),
697 Some(Binding::Single(_))
698 ) {
699 return None;
700 }
701 Some(ButtonId::GestureButton)
703 }
704
705 pub fn set_gesture_owner(&mut self, device_key: &str, button: ButtonId) {
718 self.devices
719 .entry(device_key.to_string())
720 .or_default()
721 .gesture_owner = Some(GestureOwner::Button(button));
722 self.ensure_gesture_binding(device_key, button)
723 .fill_gesture_defaults();
724 }
725
726 pub fn disable_gestures(&mut self, device_key: &str) {
730 self.devices
731 .entry(device_key.to_string())
732 .or_default()
733 .gesture_owner = Some(GestureOwner::Off);
734 }
735
736 #[must_use]
744 pub fn effective_bindings(
745 &self,
746 device_key: &str,
747 bundle_id: Option<&str>,
748 ) -> BTreeMap<ButtonId, Binding> {
749 let Some(device) = self.devices.get(device_key) else {
750 return BTreeMap::new();
751 };
752 let mut out = device.bindings.clone();
753 if let Some(bid) = bundle_id
754 && let Some(overlay) = device.per_app_bindings.get(bid)
755 {
756 for (k, v) in overlay {
757 out.insert(*k, Binding::Single(v.clone()));
758 }
759 }
760 out
761 }
762
763 pub fn set_per_app_binding(
767 &mut self,
768 device_key: &str,
769 bundle_id: &str,
770 button: ButtonId,
771 action: Option<Action>,
772 ) {
773 let entry = self
774 .devices
775 .entry(device_key.to_string())
776 .or_default()
777 .per_app_bindings
778 .entry(bundle_id.to_string())
779 .or_default();
780 match action {
781 Some(a) => {
782 entry.insert(button, a);
783 }
784 None => {
785 entry.remove(&button);
786 }
787 }
788 if let Some(d) = self.devices.get_mut(device_key) {
789 d.per_app_bindings.retain(|_, m| !m.is_empty());
790 }
791 }
792
793 #[must_use]
795 pub fn selected_device(&self) -> Option<&str> {
796 self.selected_device.as_deref()
797 }
798
799 pub fn set_selected_device(&mut self, key: Option<String>) {
802 self.selected_device = key;
803 }
804
805 #[must_use]
808 pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
809 self.devices
810 .get(device_key)
811 .map(|d| d.dpi_presets.clone())
812 .unwrap_or_default()
813 }
814
815 pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
819 self.devices
820 .entry(device_key.to_string())
821 .or_default()
822 .dpi_presets = presets;
823 }
824
825 #[must_use]
829 pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
830 self.devices
831 .get(device_key)
832 .and_then(|d| d.identity.as_ref())
833 }
834
835 pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
838 self.devices
839 .entry(device_key.to_string())
840 .or_default()
841 .identity = Some(identity);
842 }
843
844 pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
848 self.devices
849 .iter()
850 .filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
851 }
852
853 #[must_use]
855 pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
856 self.devices
857 .get(device_key)
858 .and_then(|d| d.lighting.clone())
859 }
860
861 pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
863 self.devices
864 .entry(device_key.to_string())
865 .or_default()
866 .lighting = Some(lighting);
867 }
868
869 #[must_use]
871 pub fn dpi(&self, device_key: &str) -> Option<u32> {
872 self.devices.get(device_key).and_then(|d| d.dpi)
873 }
874
875 pub fn set_dpi(&mut self, device_key: &str, dpi: u32) {
878 self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
879 }
880
881 #[must_use]
883 pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
884 self.devices.get(device_key).and_then(|d| d.smartshift)
885 }
886
887 pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
890 self.devices
891 .entry(device_key.to_string())
892 .or_default()
893 .smartshift = Some(smartshift);
894 }
895
896 #[must_use]
899 pub fn invert_scroll(&self, device_key: &str) -> bool {
900 self.devices
901 .get(device_key)
902 .is_some_and(|d| d.invert_scroll)
903 }
904
905 pub fn set_invert_scroll(&mut self, device_key: &str, invert: bool) {
908 self.devices
909 .entry(device_key.to_string())
910 .or_default()
911 .invert_scroll = invert;
912 }
913}
914
915fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
916 let tmp = path.with_extension("toml.tmp");
917 {
918 #[cfg(unix)]
919 {
920 use std::os::unix::fs::OpenOptionsExt;
921 let mut f = fs::OpenOptions::new()
922 .write(true)
923 .create(true)
924 .truncate(true)
925 .mode(0o600)
926 .open(&tmp)?;
927 io::Write::write_all(&mut f, bytes)?;
928 f.sync_all()?;
929 }
930 #[cfg(not(unix))]
931 {
932 let mut f = fs::OpenOptions::new()
933 .write(true)
934 .create(true)
935 .truncate(true)
936 .open(&tmp)?;
937 io::Write::write_all(&mut f, bytes)?;
938 f.sync_all()?;
939 }
940 }
941 fs::rename(&tmp, path)
942}
943
944#[cfg(test)]
945#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
946mod tests {
947 use super::*;
948 use crate::binding::{default_binding, default_gesture_binding};
949
950 fn write_and_read(config: &Config) -> Config {
951 let dir = tempfile::tempdir().expect("tempdir");
952 let path = dir.path().join("config.toml");
953 config.save_to_path(&path).expect("save");
954 Config::load_from_path(&path).expect("load")
955 }
956
957 #[test]
958 fn missing_file_yields_default() {
959 let dir = tempfile::tempdir().expect("tempdir");
960 let path = dir.path().join("nonexistent.toml");
961 let cfg = Config::load_from_path(&path).expect("load");
962 assert_eq!(cfg.schema_version, SCHEMA_VERSION);
963 assert!(cfg.devices.is_empty());
964 }
965
966 #[test]
967 fn lighting_roundtrips_per_device() {
968 let mut cfg = Config::default();
969 cfg.set_lighting(
970 "g513",
971 Lighting {
972 enabled: true,
973 color: "00aabb".to_string(),
974 brightness: 75,
975 },
976 );
977 let restored = write_and_read(&cfg);
978 assert_eq!(
979 restored.lighting("g513"),
980 Some(Lighting {
981 enabled: true,
982 color: "00aabb".to_string(),
983 brightness: 75,
984 })
985 );
986 assert_eq!(restored.lighting("absent"), None);
987 }
988
989 #[test]
990 fn dpi_roundtrips_per_device() {
991 let mut cfg = Config::default();
992 cfg.set_dpi("2b042", 1600);
993 let restored = write_and_read(&cfg);
994 assert_eq!(restored.dpi("2b042"), Some(1600));
995 assert_eq!(restored.dpi("absent"), None);
996 }
997
998 #[test]
999 fn smartshift_roundtrips_per_device() {
1000 let mut cfg = Config::default();
1001 cfg.set_smartshift(
1002 "2b042",
1003 SmartShift {
1004 mode: WheelMode::Ratchet,
1005 auto_disengage: 16,
1006 tunable_torque: 30,
1007 },
1008 );
1009 let restored = write_and_read(&cfg);
1010 assert_eq!(
1011 restored.smartshift("2b042"),
1012 Some(SmartShift {
1013 mode: WheelMode::Ratchet,
1014 auto_disengage: 16,
1015 tunable_torque: 30,
1016 })
1017 );
1018 assert_eq!(restored.smartshift("absent"), None);
1019 }
1020
1021 #[test]
1022 fn invert_scroll_roundtrips_per_device() {
1023 let mut cfg = Config::default();
1024 assert!(!cfg.invert_scroll("2b042"));
1026 cfg.set_invert_scroll("2b042", true);
1027 let restored = write_and_read(&cfg);
1028 assert!(restored.invert_scroll("2b042"));
1029 assert!(!restored.invert_scroll("absent"));
1030 }
1031
1032 #[test]
1033 fn default_invert_scroll_is_omitted_from_toml() {
1034 let mut cfg = Config::default();
1037 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1038 cfg.set_invert_scroll("2b042", false);
1039 let body = toml::to_string_pretty(&cfg).expect("serialize");
1040 assert!(
1041 !body.contains("invert_scroll"),
1042 "default invert_scroll should be omitted: {body}"
1043 );
1044 }
1045
1046 #[test]
1047 fn bindings_roundtrip_per_device() {
1048 let mut cfg = Config::default();
1049 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1050 cfg.set_binding(
1051 "2b042",
1052 ButtonId::DpiToggle,
1053 Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
1054 modifiers: crate::binding::KeyCombo::MOD_CMD,
1055 key_code: 0x23, display: "⌘P".into(),
1057 })),
1058 );
1059 cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
1060
1061 let parsed = write_and_read(&cfg);
1062
1063 let a = parsed.bindings_for("2b042");
1065 assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
1066 assert_eq!(
1067 a.get(&ButtonId::DpiToggle),
1068 Some(&Binding::Single(Action::CustomShortcut(
1069 crate::binding::KeyCombo {
1070 modifiers: crate::binding::KeyCombo::MOD_CMD,
1071 key_code: 0x23,
1072 display: "⌘P".into(),
1073 }
1074 )))
1075 );
1076
1077 let b = parsed.bindings_for("4082d");
1078 assert_eq!(
1079 b.get(&ButtonId::Back),
1080 Some(&Binding::Single(Action::Paste))
1081 );
1082 assert_eq!(b.len(), 1, "device b should only see its own bindings");
1083
1084 assert!(parsed.bindings_for("deadbeef").is_empty());
1086 }
1087
1088 #[test]
1089 fn human_readable_toml_layout() {
1090 let mut cfg = Config::default();
1091 cfg.set_binding(
1092 "2b042",
1093 ButtonId::Back,
1094 Binding::Single(Action::BrowserBack),
1095 );
1096 let body = toml::to_string_pretty(&cfg).expect("serialize");
1097
1098 assert!(body.contains("schema_version = 3"), "got: {body}");
1102 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1103 assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
1106 }
1107
1108 #[test]
1109 fn dpi_presets_roundtrip_per_device() {
1110 let mut cfg = Config::default();
1111 cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
1112 cfg.set_dpi_presets("4082d", vec![400, 1600]);
1113
1114 let parsed = write_and_read(&cfg);
1115
1116 assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
1117 assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
1118 assert!(parsed.dpi_presets("unknown").is_empty());
1119 }
1120
1121 #[test]
1122 fn empty_dpi_presets_skip_serialization() {
1123 let mut cfg = Config::default();
1124 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1126 cfg.set_dpi_presets("2b042", vec![800]);
1127 cfg.set_dpi_presets("2b042", vec![]); let body = toml::to_string_pretty(&cfg).expect("serialize");
1130 assert!(
1131 !body.contains("dpi_presets"),
1132 "empty dpi_presets should be omitted: {body}"
1133 );
1134 }
1135
1136 #[test]
1137 fn device_identity_roundtrips_and_is_iterable() {
1138 use crate::device::{Capabilities, DeviceKind};
1139
1140 let mut cfg = Config::default();
1141 let mouse = DeviceIdentity {
1142 display_name: "MX Master 3S".to_string(),
1143 model_info: None,
1144 codename: None,
1145 kind: DeviceKind::Mouse,
1146 capabilities: Capabilities {
1147 buttons: true,
1148 pointer: true,
1149 lighting: false,
1150 scroll_inversion: false,
1151 },
1152 };
1153 cfg.set_device_identity("2b034", mouse.clone());
1154 cfg.set_binding(
1156 "2b034",
1157 ButtonId::Back,
1158 Binding::Single(Action::BrowserBack),
1159 );
1160
1161 let parsed = write_and_read(&cfg);
1162 assert_eq!(parsed.device_identity("2b034"), Some(&mouse));
1163 assert_eq!(parsed.device_identity("absent"), None);
1164 assert_eq!(
1165 parsed.bindings_for("2b034").get(&ButtonId::Back),
1166 Some(&Binding::Single(Action::BrowserBack)),
1167 "identity must coexist with bindings on the same device block"
1168 );
1169 assert_eq!(
1170 parsed.known_identities().collect::<Vec<_>>(),
1171 vec![("2b034", &mouse)]
1172 );
1173 }
1174
1175 #[test]
1176 fn selected_device_roundtrips() {
1177 let mut cfg = Config::default();
1178 assert_eq!(cfg.selected_device(), None);
1179 cfg.set_selected_device(Some("2b042".into()));
1180 let parsed = write_and_read(&cfg);
1181 assert_eq!(parsed.selected_device(), Some("2b042"));
1182 }
1183
1184 #[test]
1185 fn per_app_overlay_takes_precedence() {
1186 let mut cfg = Config::default();
1187 cfg.set_binding(
1188 "2b042",
1189 ButtonId::Back,
1190 Binding::Single(Action::BrowserBack),
1191 );
1192 cfg.set_binding(
1193 "2b042",
1194 ButtonId::Forward,
1195 Binding::Single(Action::BrowserForward),
1196 );
1197 cfg.set_per_app_binding(
1198 "2b042",
1199 "com.microsoft.VSCode",
1200 ButtonId::Back,
1201 Some(Action::Undo),
1202 );
1203
1204 let global = cfg.effective_bindings("2b042", None);
1206 assert_eq!(
1207 global.get(&ButtonId::Back),
1208 Some(&Binding::Single(Action::BrowserBack))
1209 );
1210 assert_eq!(
1211 global.get(&ButtonId::Forward),
1212 Some(&Binding::Single(Action::BrowserForward))
1213 );
1214
1215 let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
1217 assert_eq!(
1218 vscode.get(&ButtonId::Back),
1219 Some(&Binding::Single(Action::Undo))
1220 );
1221 assert_eq!(
1222 vscode.get(&ButtonId::Forward),
1223 Some(&Binding::Single(Action::BrowserForward))
1224 );
1225
1226 let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
1228 assert_eq!(
1229 other.get(&ButtonId::Back),
1230 Some(&Binding::Single(Action::BrowserBack))
1231 );
1232 }
1233
1234 #[test]
1235 fn per_app_binding_removal_prunes_empty_app() {
1236 let mut cfg = Config::default();
1237 cfg.set_per_app_binding(
1238 "2b042",
1239 "com.example.App",
1240 ButtonId::Back,
1241 Some(Action::Copy),
1242 );
1243 cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
1244 assert!(
1245 cfg.devices["2b042"].per_app_bindings.is_empty(),
1246 "removing last override should prune the app entry"
1247 );
1248 }
1249
1250 #[test]
1251 fn app_settings_default_omits_block() {
1252 let cfg = Config::default();
1253 let body = toml::to_string_pretty(&cfg).expect("serialize");
1254 assert!(
1255 !body.contains("app_settings"),
1256 "default app_settings should be omitted: {body}"
1257 );
1258 }
1259
1260 #[test]
1261 fn app_settings_launch_at_login_roundtrips() {
1262 let mut cfg = Config::default();
1263 cfg.app_settings.launch_at_login = true;
1264 let parsed = write_and_read(&cfg);
1265 assert!(parsed.app_settings.launch_at_login);
1266 }
1267
1268 #[test]
1269 fn cleared_selected_device_omits_field() {
1270 let mut cfg = Config::default();
1271 cfg.set_selected_device(Some("2b042".into()));
1272 cfg.set_selected_device(None);
1273 let body = toml::to_string_pretty(&cfg).expect("serialize");
1274 assert!(
1275 !body.contains("selected_device"),
1276 "cleared selection should not appear: {body}"
1277 );
1278 }
1279
1280 #[test]
1281 fn empty_device_block_is_skipped_in_output() {
1282 let mut cfg = Config::default();
1285 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1286 cfg.devices
1287 .get_mut("2b042")
1288 .expect("entry")
1289 .bindings
1290 .clear();
1291 let body = toml::to_string_pretty(&cfg).expect("serialize");
1292 assert!(
1293 !body.contains("Back"),
1294 "cleared bindings should not appear: {body}"
1295 );
1296 }
1297
1298 #[test]
1299 fn migrates_v1_button_and_gesture_bindings() {
1300 let v1 = "\
1302schema_version = 1
1303
1304[devices.2b042.button_bindings]
1305Back = \"BrowserBack\"
1306
1307[devices.2b042.gesture_bindings]
1308Up = \"Copy\"
1309Click = \"Paste\"
1310";
1311 let dir = tempfile::tempdir().expect("tempdir");
1312 let path = dir.path().join("config.toml");
1313 fs::write(&path, v1).expect("write");
1314
1315 let cfg = Config::load_from_path(&path).expect("load v1");
1317 let bindings = cfg.bindings_for("2b042");
1318 assert_eq!(
1319 bindings.get(&ButtonId::Back),
1320 Some(&Binding::Single(Action::BrowserBack))
1321 );
1322 let mut gesture = BTreeMap::new();
1323 gesture.insert(GestureDirection::Up, Action::Copy);
1324 gesture.insert(GestureDirection::Click, Action::Paste);
1325 assert_eq!(
1326 bindings.get(&ButtonId::GestureButton),
1327 Some(&Binding::Gesture(gesture))
1328 );
1329
1330 let body = toml::to_string_pretty(&cfg).expect("serialize");
1333 assert!(body.contains("schema_version = 3"), "got: {body}");
1334 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1335 assert!(!body.contains("button_bindings"), "got: {body}");
1336 assert!(!body.contains("gesture_bindings"), "got: {body}");
1337 }
1338
1339 #[test]
1340 fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
1341 let v1 = "\
1346schema_version = 1
1347
1348[devices.2b042.button_bindings]
1349GestureButton = \"MissionControl\"
1350
1351[devices.2b042.gesture_bindings]
1352Up = \"Copy\"
1353Down = \"Paste\"
1354";
1355 let dir = tempfile::tempdir().expect("tempdir");
1356 let path = dir.path().join("config.toml");
1357 fs::write(&path, v1).expect("write");
1358
1359 let cfg = Config::load_from_path(&path).expect("load v1");
1360 let mut gesture = BTreeMap::new();
1361 gesture.insert(GestureDirection::Up, Action::Copy);
1362 gesture.insert(GestureDirection::Down, Action::Paste);
1363 assert_eq!(
1364 cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
1365 Some(&Binding::Gesture(gesture)),
1366 "gesture map must win over the legacy single GestureButton entry"
1367 );
1368 }
1369
1370 #[test]
1371 fn migration_drops_vestigial_lone_gesture_button_single() {
1372 let v1 = "\
1379schema_version = 1
1380
1381[devices.2b042.button_bindings]
1382GestureButton = \"MissionControl\"
1383Back = \"BrowserBack\"
1384";
1385 let dir = tempfile::tempdir().expect("tempdir");
1386 let path = dir.path().join("config.toml");
1387 fs::write(&path, v1).expect("write");
1388
1389 let bindings = Config::load_from_path(&path)
1390 .expect("load v1")
1391 .bindings_for("2b042");
1392 assert_eq!(
1394 bindings.get(&ButtonId::Back),
1395 Some(&Binding::Single(Action::BrowserBack))
1396 );
1397 assert_eq!(bindings.get(&ButtonId::GestureButton), None);
1400 }
1401
1402 #[test]
1403 fn rejects_newer_schema_version_but_accepts_v1() {
1404 let dir = tempfile::tempdir().expect("tempdir");
1407 let path = dir.path().join("config.toml");
1408 fs::write(&path, "schema_version = 99\n").expect("write");
1409 assert!(matches!(
1410 Config::load_from_path(&path).expect_err("v99 should fail"),
1411 ConfigError::UnsupportedSchemaVersion { found: 99, .. }
1412 ));
1413
1414 fs::write(&path, "schema_version = 1\n").expect("write");
1415 assert!(
1416 Config::load_from_path(&path).is_ok(),
1417 "v1 should still load"
1418 );
1419 }
1420
1421 #[test]
1422 fn set_gesture_direction_upgrades_single_to_gesture() {
1423 let mut cfg = Config::default();
1424 cfg.set_binding(
1426 "2b042",
1427 ButtonId::Back,
1428 Binding::Single(Action::BrowserBack),
1429 );
1430 cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
1431
1432 match cfg.bindings_for("2b042").get(&ButtonId::Back) {
1433 Some(Binding::Gesture(map)) => {
1434 assert_eq!(
1436 map.get(&GestureDirection::Click),
1437 Some(&Action::BrowserBack)
1438 );
1439 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1440 }
1441 other => panic!("expected Gesture after upgrade, got {other:?}"),
1442 }
1443 }
1444
1445 #[test]
1446 fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
1447 let mut cfg = Config::default();
1451 cfg.set_gesture_direction(
1452 "2b042",
1453 ButtonId::GestureButton,
1454 GestureDirection::Up,
1455 Action::Copy,
1456 );
1457
1458 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1459 Some(Binding::Gesture(map)) => {
1460 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1461 assert_eq!(
1462 map.get(&GestureDirection::Click),
1463 Some(&crate::binding::default_gesture_binding(
1464 GestureDirection::Click
1465 )),
1466 "a fresh gesture button must seed a Click from its default"
1467 );
1468 }
1469 other => panic!("expected Gesture, got {other:?}"),
1470 }
1471 }
1472
1473 #[test]
1474 fn gesture_owner_defaults_to_thumb_pad_yields_to_oshook_and_can_be_off() {
1475 let mut cfg = Config::default();
1476 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1478
1479 cfg.set_gesture_direction(
1481 "2b042",
1482 ButtonId::GestureButton,
1483 GestureDirection::Up,
1484 Action::MissionControl,
1485 );
1486 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1487
1488 cfg.set_binding(
1490 "2b042",
1491 ButtonId::Forward,
1492 Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
1493 );
1494 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
1495
1496 let mut off = Config::default();
1498 off.disable_gestures("2b042");
1499 assert_eq!(off.gesture_owner("2b042"), None);
1500 }
1501
1502 #[test]
1503 fn set_gesture_owner_records_owner_without_destroying_other_maps() {
1504 let mut cfg = Config::default();
1505 cfg.set_gesture_direction(
1507 "2b042",
1508 ButtonId::GestureButton,
1509 GestureDirection::Up,
1510 Action::Copy,
1511 );
1512 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1513
1514 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
1517 cfg.set_gesture_owner("2b042", ButtonId::Back);
1518 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
1519
1520 let bindings = cfg.bindings_for("2b042");
1521 match bindings.get(&ButtonId::Back) {
1524 Some(Binding::Gesture(map)) => {
1525 assert_eq!(
1526 map.get(&GestureDirection::Click),
1527 Some(&Action::BrowserBack)
1528 );
1529 assert_eq!(
1530 map.get(&GestureDirection::Up),
1531 Some(&default_gesture_binding(GestureDirection::Up)),
1532 "a promoted button gets full default arms"
1533 );
1534 }
1535 other => panic!("expected Back to be a gesture binding, got {other:?}"),
1536 }
1537 match bindings.get(&ButtonId::GestureButton) {
1539 Some(Binding::Gesture(map)) => {
1540 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1541 }
1542 other => panic!("expected the thumb pad map preserved, got {other:?}"),
1543 }
1544
1545 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1548 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1549 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1550 Some(Binding::Gesture(map)) => {
1551 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1552 }
1553 other => panic!("expected preserved gesture map, got {other:?}"),
1554 }
1555 }
1556
1557 #[test]
1558 fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
1559 let mut cfg = Config::default();
1560 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1562 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1563 Some(Binding::Gesture(map)) => {
1564 for dir in GestureDirection::ALL {
1565 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1566 }
1567 }
1568 other => panic!("expected full default gesture map, got {other:?}"),
1569 }
1570
1571 cfg.set_gesture_owner("2b042", ButtonId::Forward);
1575 match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
1576 Some(Binding::Gesture(map)) => {
1577 assert_eq!(
1578 map.get(&GestureDirection::Click),
1579 Some(&default_binding(ButtonId::Forward))
1580 );
1581 for dir in [
1582 GestureDirection::Up,
1583 GestureDirection::Down,
1584 GestureDirection::Left,
1585 GestureDirection::Right,
1586 ] {
1587 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1588 }
1589 }
1590 other => panic!("expected full gesture map for Forward, got {other:?}"),
1591 }
1592 }
1593
1594 #[test]
1595 fn disable_gestures_turns_off_without_destroying_maps() {
1596 let mut cfg = Config::default();
1597 cfg.set_gesture_direction(
1598 "2b042",
1599 ButtonId::GestureButton,
1600 GestureDirection::Up,
1601 Action::Copy,
1602 );
1603 cfg.disable_gestures("2b042");
1604 assert_eq!(cfg.gesture_owner("2b042"), None);
1607 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1608 Some(Binding::Gesture(map)) => {
1609 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1610 }
1611 other => panic!("expected the gesture map preserved while off, got {other:?}"),
1612 }
1613 }
1614
1615 #[test]
1616 fn gesture_owner_field_roundtrips_as_a_scalar() {
1617 let mut cfg = Config::default();
1618 cfg.set_gesture_owner("2b042", ButtonId::Back); cfg.disable_gestures("4082d"); let parsed = write_and_read(&cfg);
1622 assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
1623 assert_eq!(parsed.gesture_owner("4082d"), None);
1624
1625 let body = toml::to_string_pretty(&cfg).expect("serialize");
1628 assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
1629 assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
1630 }
1631
1632 #[test]
1633 fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
1634 let toml = "\
1638schema_version = 2
1639
1640[devices.2b042]
1641gesture_owner = \"bogus\"
1642
1643[devices.2b042.bindings]
1644Back = \"Copy\"
1645";
1646 let dir = tempfile::tempdir().expect("tempdir");
1647 let path = dir.path().join("config.toml");
1648 fs::write(&path, toml).expect("write");
1649
1650 let cfg =
1651 Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
1652 assert_eq!(
1654 cfg.bindings_for("2b042").get(&ButtonId::Back),
1655 Some(&Binding::Single(Action::Copy))
1656 );
1657 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1659 }
1660}