1use std::{
11 collections::BTreeMap,
12 fs, io,
13 path::{Path, PathBuf},
14};
15
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19use crate::binding::{Action, Binding, ButtonId, GestureDirection, default_binding_for};
20use crate::device::{Capabilities, DeviceKind};
21use crate::paths::{self, PathsError};
22
23pub const SCHEMA_VERSION: u32 = 2;
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Config {
37 pub schema_version: u32,
38 #[serde(default, skip_serializing_if = "AppSettings::is_default")]
40 pub app_settings: AppSettings,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub selected_device: Option<String>,
46 #[serde(default)]
47 pub devices: BTreeMap<String, DeviceConfig>,
48}
49
50impl Default for Config {
51 fn default() -> Self {
52 Self {
53 schema_version: SCHEMA_VERSION,
54 app_settings: AppSettings::default(),
55 selected_device: None,
56 devices: BTreeMap::new(),
57 }
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[allow(
67 clippy::struct_excessive_bools,
68 reason = "independent on/off user preferences, not a state machine"
69)]
70pub struct AppSettings {
71 #[serde(default)]
77 pub launch_at_login: bool,
78 #[serde(default)]
84 pub check_for_updates: bool,
85 #[serde(default)]
90 pub update_prompt_seen: bool,
91 #[serde(default = "default_true")]
96 pub show_in_menu_bar: bool,
97 #[serde(default = "default_true")]
103 pub auto_download_assets: bool,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub language: Option<String>,
111 #[serde(default = "default_thumbwheel_sensitivity")]
118 pub thumbwheel_sensitivity: i32,
119}
120
121pub const DEFAULT_THUMBWHEEL_SENSITIVITY: i32 = 14;
125pub const MIN_THUMBWHEEL_SENSITIVITY: i32 = 1;
127pub const MAX_THUMBWHEEL_SENSITIVITY: i32 = 100;
129
130impl AppSettings {
131 #[must_use]
134 pub fn is_default(&self) -> bool {
135 self == &Self::default()
136 }
137}
138
139impl Default for AppSettings {
140 fn default() -> Self {
141 Self {
142 launch_at_login: false,
143 check_for_updates: false,
144 update_prompt_seen: false,
145 show_in_menu_bar: true,
146 auto_download_assets: true,
147 language: None,
148 thumbwheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY,
149 }
150 }
151}
152
153fn default_true() -> bool {
156 true
157}
158
159const fn default_thumbwheel_sensitivity() -> i32 {
162 DEFAULT_THUMBWHEEL_SENSITIVITY
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct Lighting {
173 #[serde(default = "default_lighting_enabled")]
174 pub enabled: bool,
175 #[serde(default = "default_lighting_color")]
177 pub color: String,
178 #[serde(
180 default = "default_lighting_brightness",
181 deserialize_with = "deserialize_brightness"
182 )]
183 pub brightness: u8,
184}
185
186impl Default for Lighting {
187 fn default() -> Self {
188 Self {
189 enabled: default_lighting_enabled(),
190 color: default_lighting_color(),
191 brightness: default_lighting_brightness(),
192 }
193 }
194}
195
196fn default_lighting_enabled() -> bool {
197 true
198}
199
200fn default_lighting_color() -> String {
201 "ffffff".to_string()
202}
203
204fn default_lighting_brightness() -> u8 {
205 100
206}
207
208fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
212where
213 D: serde::Deserializer<'de>,
214{
215 Ok(u8::deserialize(deserializer)?.min(100))
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "snake_case")]
221pub enum WheelMode {
222 Free,
223 Ratchet,
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
235pub struct SmartShift {
236 pub mode: WheelMode,
237 pub auto_disengage: u8,
240 pub tunable_torque: u8,
243}
244
245#[derive(Clone, Copy, Debug, PartialEq, Eq)]
253pub enum GestureOwner {
254 Off,
256 Button(ButtonId),
258}
259
260impl Serialize for GestureOwner {
261 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
262 match self {
263 GestureOwner::Off => serializer.serialize_str("Off"),
266 GestureOwner::Button(id) => id.serialize(serializer),
267 }
268 }
269}
270
271fn deserialize_gesture_owner<'de, D>(deserializer: D) -> Result<Option<GestureOwner>, D::Error>
278where
279 D: serde::Deserializer<'de>,
280{
281 let s = String::deserialize(deserializer)?;
282 if s == "Off" {
283 return Ok(Some(GestureOwner::Off));
284 }
285 let button = ButtonId::deserialize(
288 serde::de::value::StrDeserializer::<serde::de::value::Error>::new(&s),
289 )
290 .ok();
291 Ok(button.map(GestureOwner::Button))
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307pub struct DeviceIdentity {
308 pub display_name: String,
311 pub kind: DeviceKind,
314 pub capabilities: Capabilities,
317}
318
319#[derive(Debug, Clone, Default, Serialize, Deserialize)]
327#[serde(from = "RawDeviceConfig")]
328pub struct DeviceConfig {
329 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub gesture_owner: Option<GestureOwner>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub identity: Option<DeviceIdentity>,
341 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
345 pub bindings: BTreeMap<ButtonId, Binding>,
346 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
353 pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
354 #[serde(default, skip_serializing_if = "Vec::is_empty")]
359 pub dpi_presets: Vec<u32>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub dpi: Option<u32>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub lighting: Option<Lighting>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub smartshift: Option<SmartShift>,
374 #[serde(default, skip_serializing_if = "is_false")]
381 pub invert_scroll: bool,
382}
383
384#[allow(
387 clippy::trivially_copy_pass_by_ref,
388 reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
389)]
390fn is_false(b: &bool) -> bool {
391 !*b
392}
393
394#[derive(Deserialize)]
399struct RawDeviceConfig {
400 #[serde(default, deserialize_with = "deserialize_gesture_owner")]
405 gesture_owner: Option<GestureOwner>,
406 #[serde(default)]
407 identity: Option<DeviceIdentity>,
408 #[serde(default)]
410 bindings: BTreeMap<ButtonId, Binding>,
411 #[serde(default)]
413 button_bindings: BTreeMap<ButtonId, Action>,
414 #[serde(default)]
416 gesture_bindings: BTreeMap<GestureDirection, Action>,
417 #[serde(default)]
418 per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
419 #[serde(default)]
420 dpi_presets: Vec<u32>,
421 #[serde(default)]
422 dpi: Option<u32>,
423 #[serde(default)]
424 lighting: Option<Lighting>,
425 #[serde(default)]
426 smartshift: Option<SmartShift>,
427 #[serde(default)]
428 invert_scroll: bool,
429}
430
431impl From<RawDeviceConfig> for DeviceConfig {
432 fn from(raw: RawDeviceConfig) -> Self {
433 let mut bindings = raw.bindings; if !raw.gesture_bindings.is_empty() {
441 bindings
442 .entry(ButtonId::GestureButton)
443 .or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
444 }
445 for (button, action) in raw.button_bindings {
446 if button == ButtonId::GestureButton {
457 continue;
458 }
459 bindings.entry(button).or_insert(Binding::Single(action));
460 }
461
462 DeviceConfig {
463 gesture_owner: raw.gesture_owner,
464 identity: raw.identity,
465 bindings,
466 per_app_bindings: raw.per_app_bindings,
467 dpi_presets: raw.dpi_presets,
468 dpi: raw.dpi,
469 lighting: raw.lighting,
470 smartshift: raw.smartshift,
471 invert_scroll: raw.invert_scroll,
472 }
473 }
474}
475
476#[derive(Debug, Error)]
477pub enum ConfigError {
478 #[error("could not resolve config path")]
479 Path(#[from] PathsError),
480 #[error("could not read config at {path}")]
481 Read {
482 path: PathBuf,
483 #[source]
484 source: io::Error,
485 },
486 #[error("could not parse config at {path}")]
487 Parse {
488 path: PathBuf,
489 #[source]
490 source: toml::de::Error,
491 },
492 #[error("could not write config at {path}")]
493 Write {
494 path: PathBuf,
495 #[source]
496 source: io::Error,
497 },
498 #[error("could not serialize config")]
499 Serialize(#[from] toml::ser::Error),
500 #[error("config at {path} has unsupported schema_version {found}")]
501 UnsupportedSchemaVersion { path: PathBuf, found: u32 },
502}
503
504#[allow(
505 clippy::result_large_err,
506 reason = "Config I/O keeps rich parse/write context and is not a hot path"
507)]
508impl Config {
509 pub fn load_or_default() -> Result<Self, ConfigError> {
512 Self::load_from_path(&paths::config_path()?)
513 }
514
515 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
518 match fs::read_to_string(path) {
519 Ok(text) => {
520 let mut config: Self =
521 toml::from_str(&text).map_err(|source| ConfigError::Parse {
522 path: path.to_path_buf(),
523 source,
524 })?;
525 if config.schema_version > SCHEMA_VERSION {
531 return Err(ConfigError::UnsupportedSchemaVersion {
532 path: path.to_path_buf(),
533 found: config.schema_version,
534 });
535 }
536 config.schema_version = SCHEMA_VERSION;
540 Ok(config)
541 }
542 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
543 Err(source) => Err(ConfigError::Read {
544 path: path.to_path_buf(),
545 source,
546 }),
547 }
548 }
549
550 pub fn save_atomic(&self) -> Result<(), ConfigError> {
554 self.save_to_path(&paths::config_path()?)
555 }
556
557 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
559 if let Some(parent) = path.parent() {
560 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
561 path: path.to_path_buf(),
562 source,
563 })?;
564 }
565 let body = toml::to_string_pretty(self)?;
566 write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
567 path: path.to_path_buf(),
568 source,
569 })
570 }
571
572 #[must_use]
575 pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
576 self.devices
577 .get(device_key)
578 .map(|d| d.bindings.clone())
579 .unwrap_or_default()
580 }
581
582 pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
587 self.devices
588 .entry(device_key.to_string())
589 .or_default()
590 .bindings
591 .insert(button, binding);
592 }
593
594 #[must_use]
599 pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
600 match self
601 .devices
602 .get(device_key)
603 .and_then(|d| d.bindings.get(&ButtonId::GestureButton))
604 {
605 Some(Binding::Gesture(map)) => map.clone(),
606 _ => BTreeMap::new(),
607 }
608 }
609
610 pub fn set_gesture_direction(
620 &mut self,
621 device_key: &str,
622 button: ButtonId,
623 direction: GestureDirection,
624 action: Action,
625 ) {
626 if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
627 map.insert(direction, action);
628 }
629 }
630
631 fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
639 let entry = self
640 .devices
641 .entry(device_key.to_string())
642 .or_default()
643 .bindings
644 .entry(button)
645 .or_insert_with(|| default_binding_for(button));
646 entry.upgrade_to_gesture();
647 entry
648 }
649
650 #[must_use]
659 pub fn gesture_owner(&self, device_key: &str) -> Option<ButtonId> {
660 let Some(device) = self.devices.get(device_key) else {
661 return Some(ButtonId::GestureButton);
663 };
664 match device.gesture_owner {
665 Some(GestureOwner::Off) => None,
666 Some(GestureOwner::Button(id)) => Some(id),
667 None => Self::infer_gesture_owner(&device.bindings),
668 }
669 }
670
671 fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
676 if let Some((id, _)) = bindings
678 .iter()
679 .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
680 {
681 return Some(*id);
682 }
683 if matches!(
685 bindings.get(&ButtonId::GestureButton),
686 Some(Binding::Single(_))
687 ) {
688 return None;
689 }
690 Some(ButtonId::GestureButton)
692 }
693
694 pub fn set_gesture_owner(&mut self, device_key: &str, button: ButtonId) {
707 self.devices
708 .entry(device_key.to_string())
709 .or_default()
710 .gesture_owner = Some(GestureOwner::Button(button));
711 self.ensure_gesture_binding(device_key, button)
712 .fill_gesture_defaults();
713 }
714
715 pub fn disable_gestures(&mut self, device_key: &str) {
719 self.devices
720 .entry(device_key.to_string())
721 .or_default()
722 .gesture_owner = Some(GestureOwner::Off);
723 }
724
725 #[must_use]
733 pub fn effective_bindings(
734 &self,
735 device_key: &str,
736 bundle_id: Option<&str>,
737 ) -> BTreeMap<ButtonId, Binding> {
738 let Some(device) = self.devices.get(device_key) else {
739 return BTreeMap::new();
740 };
741 let mut out = device.bindings.clone();
742 if let Some(bid) = bundle_id
743 && let Some(overlay) = device.per_app_bindings.get(bid)
744 {
745 for (k, v) in overlay {
746 out.insert(*k, Binding::Single(v.clone()));
747 }
748 }
749 out
750 }
751
752 pub fn set_per_app_binding(
756 &mut self,
757 device_key: &str,
758 bundle_id: &str,
759 button: ButtonId,
760 action: Option<Action>,
761 ) {
762 let entry = self
763 .devices
764 .entry(device_key.to_string())
765 .or_default()
766 .per_app_bindings
767 .entry(bundle_id.to_string())
768 .or_default();
769 match action {
770 Some(a) => {
771 entry.insert(button, a);
772 }
773 None => {
774 entry.remove(&button);
775 }
776 }
777 if let Some(d) = self.devices.get_mut(device_key) {
778 d.per_app_bindings.retain(|_, m| !m.is_empty());
779 }
780 }
781
782 #[must_use]
784 pub fn selected_device(&self) -> Option<&str> {
785 self.selected_device.as_deref()
786 }
787
788 pub fn set_selected_device(&mut self, key: Option<String>) {
791 self.selected_device = key;
792 }
793
794 #[must_use]
797 pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
798 self.devices
799 .get(device_key)
800 .map(|d| d.dpi_presets.clone())
801 .unwrap_or_default()
802 }
803
804 pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
808 self.devices
809 .entry(device_key.to_string())
810 .or_default()
811 .dpi_presets = presets;
812 }
813
814 #[must_use]
818 pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
819 self.devices
820 .get(device_key)
821 .and_then(|d| d.identity.as_ref())
822 }
823
824 pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
827 self.devices
828 .entry(device_key.to_string())
829 .or_default()
830 .identity = Some(identity);
831 }
832
833 pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
837 self.devices
838 .iter()
839 .filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
840 }
841
842 #[must_use]
844 pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
845 self.devices
846 .get(device_key)
847 .and_then(|d| d.lighting.clone())
848 }
849
850 pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
852 self.devices
853 .entry(device_key.to_string())
854 .or_default()
855 .lighting = Some(lighting);
856 }
857
858 #[must_use]
860 pub fn dpi(&self, device_key: &str) -> Option<u32> {
861 self.devices.get(device_key).and_then(|d| d.dpi)
862 }
863
864 pub fn set_dpi(&mut self, device_key: &str, dpi: u32) {
867 self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
868 }
869
870 #[must_use]
872 pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
873 self.devices.get(device_key).and_then(|d| d.smartshift)
874 }
875
876 pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
879 self.devices
880 .entry(device_key.to_string())
881 .or_default()
882 .smartshift = Some(smartshift);
883 }
884
885 #[must_use]
888 pub fn invert_scroll(&self, device_key: &str) -> bool {
889 self.devices
890 .get(device_key)
891 .is_some_and(|d| d.invert_scroll)
892 }
893
894 pub fn set_invert_scroll(&mut self, device_key: &str, invert: bool) {
897 self.devices
898 .entry(device_key.to_string())
899 .or_default()
900 .invert_scroll = invert;
901 }
902}
903
904fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
905 let tmp = path.with_extension("toml.tmp");
906 {
907 #[cfg(unix)]
908 {
909 use std::os::unix::fs::OpenOptionsExt;
910 let mut f = fs::OpenOptions::new()
911 .write(true)
912 .create(true)
913 .truncate(true)
914 .mode(0o600)
915 .open(&tmp)?;
916 io::Write::write_all(&mut f, bytes)?;
917 f.sync_all()?;
918 }
919 #[cfg(not(unix))]
920 {
921 let mut f = fs::OpenOptions::new()
922 .write(true)
923 .create(true)
924 .truncate(true)
925 .open(&tmp)?;
926 io::Write::write_all(&mut f, bytes)?;
927 f.sync_all()?;
928 }
929 }
930 fs::rename(&tmp, path)
931}
932
933#[cfg(test)]
934#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
935mod tests {
936 use super::*;
937 use crate::binding::{default_binding, default_gesture_binding};
938
939 fn write_and_read(config: &Config) -> Config {
940 let dir = tempfile::tempdir().expect("tempdir");
941 let path = dir.path().join("config.toml");
942 config.save_to_path(&path).expect("save");
943 Config::load_from_path(&path).expect("load")
944 }
945
946 #[test]
947 fn missing_file_yields_default() {
948 let dir = tempfile::tempdir().expect("tempdir");
949 let path = dir.path().join("nonexistent.toml");
950 let cfg = Config::load_from_path(&path).expect("load");
951 assert_eq!(cfg.schema_version, SCHEMA_VERSION);
952 assert!(cfg.devices.is_empty());
953 }
954
955 #[test]
956 fn lighting_roundtrips_per_device() {
957 let mut cfg = Config::default();
958 cfg.set_lighting(
959 "g513",
960 Lighting {
961 enabled: true,
962 color: "00aabb".to_string(),
963 brightness: 75,
964 },
965 );
966 let restored = write_and_read(&cfg);
967 assert_eq!(
968 restored.lighting("g513"),
969 Some(Lighting {
970 enabled: true,
971 color: "00aabb".to_string(),
972 brightness: 75,
973 })
974 );
975 assert_eq!(restored.lighting("absent"), None);
976 }
977
978 #[test]
979 fn dpi_roundtrips_per_device() {
980 let mut cfg = Config::default();
981 cfg.set_dpi("2b042", 1600);
982 let restored = write_and_read(&cfg);
983 assert_eq!(restored.dpi("2b042"), Some(1600));
984 assert_eq!(restored.dpi("absent"), None);
985 }
986
987 #[test]
988 fn smartshift_roundtrips_per_device() {
989 let mut cfg = Config::default();
990 cfg.set_smartshift(
991 "2b042",
992 SmartShift {
993 mode: WheelMode::Ratchet,
994 auto_disengage: 16,
995 tunable_torque: 30,
996 },
997 );
998 let restored = write_and_read(&cfg);
999 assert_eq!(
1000 restored.smartshift("2b042"),
1001 Some(SmartShift {
1002 mode: WheelMode::Ratchet,
1003 auto_disengage: 16,
1004 tunable_torque: 30,
1005 })
1006 );
1007 assert_eq!(restored.smartshift("absent"), None);
1008 }
1009
1010 #[test]
1011 fn invert_scroll_roundtrips_per_device() {
1012 let mut cfg = Config::default();
1013 assert!(!cfg.invert_scroll("2b042"));
1015 cfg.set_invert_scroll("2b042", true);
1016 let restored = write_and_read(&cfg);
1017 assert!(restored.invert_scroll("2b042"));
1018 assert!(!restored.invert_scroll("absent"));
1019 }
1020
1021 #[test]
1022 fn default_invert_scroll_is_omitted_from_toml() {
1023 let mut cfg = Config::default();
1026 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1027 cfg.set_invert_scroll("2b042", false);
1028 let body = toml::to_string_pretty(&cfg).expect("serialize");
1029 assert!(
1030 !body.contains("invert_scroll"),
1031 "default invert_scroll should be omitted: {body}"
1032 );
1033 }
1034
1035 #[test]
1036 fn bindings_roundtrip_per_device() {
1037 let mut cfg = Config::default();
1038 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1039 cfg.set_binding(
1040 "2b042",
1041 ButtonId::DpiToggle,
1042 Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
1043 modifiers: crate::binding::KeyCombo::MOD_CMD,
1044 key_code: 0x23, display: "⌘P".into(),
1046 })),
1047 );
1048 cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
1049
1050 let parsed = write_and_read(&cfg);
1051
1052 let a = parsed.bindings_for("2b042");
1054 assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
1055 assert_eq!(
1056 a.get(&ButtonId::DpiToggle),
1057 Some(&Binding::Single(Action::CustomShortcut(
1058 crate::binding::KeyCombo {
1059 modifiers: crate::binding::KeyCombo::MOD_CMD,
1060 key_code: 0x23,
1061 display: "⌘P".into(),
1062 }
1063 )))
1064 );
1065
1066 let b = parsed.bindings_for("4082d");
1067 assert_eq!(
1068 b.get(&ButtonId::Back),
1069 Some(&Binding::Single(Action::Paste))
1070 );
1071 assert_eq!(b.len(), 1, "device b should only see its own bindings");
1072
1073 assert!(parsed.bindings_for("deadbeef").is_empty());
1075 }
1076
1077 #[test]
1078 fn human_readable_toml_layout() {
1079 let mut cfg = Config::default();
1080 cfg.set_binding(
1081 "2b042",
1082 ButtonId::Back,
1083 Binding::Single(Action::BrowserBack),
1084 );
1085 let body = toml::to_string_pretty(&cfg).expect("serialize");
1086
1087 assert!(body.contains("schema_version = 2"), "got: {body}");
1091 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1092 assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
1095 }
1096
1097 #[test]
1098 fn dpi_presets_roundtrip_per_device() {
1099 let mut cfg = Config::default();
1100 cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
1101 cfg.set_dpi_presets("4082d", vec![400, 1600]);
1102
1103 let parsed = write_and_read(&cfg);
1104
1105 assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
1106 assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
1107 assert!(parsed.dpi_presets("unknown").is_empty());
1108 }
1109
1110 #[test]
1111 fn empty_dpi_presets_skip_serialization() {
1112 let mut cfg = Config::default();
1113 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1115 cfg.set_dpi_presets("2b042", vec![800]);
1116 cfg.set_dpi_presets("2b042", vec![]); let body = toml::to_string_pretty(&cfg).expect("serialize");
1119 assert!(
1120 !body.contains("dpi_presets"),
1121 "empty dpi_presets should be omitted: {body}"
1122 );
1123 }
1124
1125 #[test]
1126 fn device_identity_roundtrips_and_is_iterable() {
1127 use crate::device::{Capabilities, DeviceKind};
1128
1129 let mut cfg = Config::default();
1130 let mouse = DeviceIdentity {
1131 display_name: "MX Master 3S".to_string(),
1132 kind: DeviceKind::Mouse,
1133 capabilities: Capabilities {
1134 buttons: true,
1135 pointer: true,
1136 lighting: false,
1137 },
1138 };
1139 cfg.set_device_identity("2b034", mouse.clone());
1140 cfg.set_binding(
1142 "2b034",
1143 ButtonId::Back,
1144 Binding::Single(Action::BrowserBack),
1145 );
1146
1147 let parsed = write_and_read(&cfg);
1148 assert_eq!(parsed.device_identity("2b034"), Some(&mouse));
1149 assert_eq!(parsed.device_identity("absent"), None);
1150 assert_eq!(
1151 parsed.bindings_for("2b034").get(&ButtonId::Back),
1152 Some(&Binding::Single(Action::BrowserBack)),
1153 "identity must coexist with bindings on the same device block"
1154 );
1155 assert_eq!(
1156 parsed.known_identities().collect::<Vec<_>>(),
1157 vec![("2b034", &mouse)]
1158 );
1159 }
1160
1161 #[test]
1162 fn selected_device_roundtrips() {
1163 let mut cfg = Config::default();
1164 assert_eq!(cfg.selected_device(), None);
1165 cfg.set_selected_device(Some("2b042".into()));
1166 let parsed = write_and_read(&cfg);
1167 assert_eq!(parsed.selected_device(), Some("2b042"));
1168 }
1169
1170 #[test]
1171 fn per_app_overlay_takes_precedence() {
1172 let mut cfg = Config::default();
1173 cfg.set_binding(
1174 "2b042",
1175 ButtonId::Back,
1176 Binding::Single(Action::BrowserBack),
1177 );
1178 cfg.set_binding(
1179 "2b042",
1180 ButtonId::Forward,
1181 Binding::Single(Action::BrowserForward),
1182 );
1183 cfg.set_per_app_binding(
1184 "2b042",
1185 "com.microsoft.VSCode",
1186 ButtonId::Back,
1187 Some(Action::Undo),
1188 );
1189
1190 let global = cfg.effective_bindings("2b042", None);
1192 assert_eq!(
1193 global.get(&ButtonId::Back),
1194 Some(&Binding::Single(Action::BrowserBack))
1195 );
1196 assert_eq!(
1197 global.get(&ButtonId::Forward),
1198 Some(&Binding::Single(Action::BrowserForward))
1199 );
1200
1201 let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
1203 assert_eq!(
1204 vscode.get(&ButtonId::Back),
1205 Some(&Binding::Single(Action::Undo))
1206 );
1207 assert_eq!(
1208 vscode.get(&ButtonId::Forward),
1209 Some(&Binding::Single(Action::BrowserForward))
1210 );
1211
1212 let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
1214 assert_eq!(
1215 other.get(&ButtonId::Back),
1216 Some(&Binding::Single(Action::BrowserBack))
1217 );
1218 }
1219
1220 #[test]
1221 fn per_app_binding_removal_prunes_empty_app() {
1222 let mut cfg = Config::default();
1223 cfg.set_per_app_binding(
1224 "2b042",
1225 "com.example.App",
1226 ButtonId::Back,
1227 Some(Action::Copy),
1228 );
1229 cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
1230 assert!(
1231 cfg.devices["2b042"].per_app_bindings.is_empty(),
1232 "removing last override should prune the app entry"
1233 );
1234 }
1235
1236 #[test]
1237 fn app_settings_default_omits_block() {
1238 let cfg = Config::default();
1239 let body = toml::to_string_pretty(&cfg).expect("serialize");
1240 assert!(
1241 !body.contains("app_settings"),
1242 "default app_settings should be omitted: {body}"
1243 );
1244 }
1245
1246 #[test]
1247 fn app_settings_launch_at_login_roundtrips() {
1248 let mut cfg = Config::default();
1249 cfg.app_settings.launch_at_login = true;
1250 let parsed = write_and_read(&cfg);
1251 assert!(parsed.app_settings.launch_at_login);
1252 }
1253
1254 #[test]
1255 fn cleared_selected_device_omits_field() {
1256 let mut cfg = Config::default();
1257 cfg.set_selected_device(Some("2b042".into()));
1258 cfg.set_selected_device(None);
1259 let body = toml::to_string_pretty(&cfg).expect("serialize");
1260 assert!(
1261 !body.contains("selected_device"),
1262 "cleared selection should not appear: {body}"
1263 );
1264 }
1265
1266 #[test]
1267 fn empty_device_block_is_skipped_in_output() {
1268 let mut cfg = Config::default();
1271 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1272 cfg.devices
1273 .get_mut("2b042")
1274 .expect("entry")
1275 .bindings
1276 .clear();
1277 let body = toml::to_string_pretty(&cfg).expect("serialize");
1278 assert!(
1279 !body.contains("Back"),
1280 "cleared bindings should not appear: {body}"
1281 );
1282 }
1283
1284 #[test]
1285 fn migrates_v1_button_and_gesture_bindings() {
1286 let v1 = "\
1288schema_version = 1
1289
1290[devices.2b042.button_bindings]
1291Back = \"BrowserBack\"
1292
1293[devices.2b042.gesture_bindings]
1294Up = \"Copy\"
1295Click = \"Paste\"
1296";
1297 let dir = tempfile::tempdir().expect("tempdir");
1298 let path = dir.path().join("config.toml");
1299 fs::write(&path, v1).expect("write");
1300
1301 let cfg = Config::load_from_path(&path).expect("load v1");
1303 let bindings = cfg.bindings_for("2b042");
1304 assert_eq!(
1305 bindings.get(&ButtonId::Back),
1306 Some(&Binding::Single(Action::BrowserBack))
1307 );
1308 let mut gesture = BTreeMap::new();
1309 gesture.insert(GestureDirection::Up, Action::Copy);
1310 gesture.insert(GestureDirection::Click, Action::Paste);
1311 assert_eq!(
1312 bindings.get(&ButtonId::GestureButton),
1313 Some(&Binding::Gesture(gesture))
1314 );
1315
1316 let body = toml::to_string_pretty(&cfg).expect("serialize");
1319 assert!(body.contains("schema_version = 2"), "got: {body}");
1320 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1321 assert!(!body.contains("button_bindings"), "got: {body}");
1322 assert!(!body.contains("gesture_bindings"), "got: {body}");
1323 }
1324
1325 #[test]
1326 fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
1327 let v1 = "\
1332schema_version = 1
1333
1334[devices.2b042.button_bindings]
1335GestureButton = \"MissionControl\"
1336
1337[devices.2b042.gesture_bindings]
1338Up = \"Copy\"
1339Down = \"Paste\"
1340";
1341 let dir = tempfile::tempdir().expect("tempdir");
1342 let path = dir.path().join("config.toml");
1343 fs::write(&path, v1).expect("write");
1344
1345 let cfg = Config::load_from_path(&path).expect("load v1");
1346 let mut gesture = BTreeMap::new();
1347 gesture.insert(GestureDirection::Up, Action::Copy);
1348 gesture.insert(GestureDirection::Down, Action::Paste);
1349 assert_eq!(
1350 cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
1351 Some(&Binding::Gesture(gesture)),
1352 "gesture map must win over the legacy single GestureButton entry"
1353 );
1354 }
1355
1356 #[test]
1357 fn migration_drops_vestigial_lone_gesture_button_single() {
1358 let v1 = "\
1365schema_version = 1
1366
1367[devices.2b042.button_bindings]
1368GestureButton = \"MissionControl\"
1369Back = \"BrowserBack\"
1370";
1371 let dir = tempfile::tempdir().expect("tempdir");
1372 let path = dir.path().join("config.toml");
1373 fs::write(&path, v1).expect("write");
1374
1375 let bindings = Config::load_from_path(&path)
1376 .expect("load v1")
1377 .bindings_for("2b042");
1378 assert_eq!(
1380 bindings.get(&ButtonId::Back),
1381 Some(&Binding::Single(Action::BrowserBack))
1382 );
1383 assert_eq!(bindings.get(&ButtonId::GestureButton), None);
1386 }
1387
1388 #[test]
1389 fn rejects_newer_schema_version_but_accepts_v1() {
1390 let dir = tempfile::tempdir().expect("tempdir");
1393 let path = dir.path().join("config.toml");
1394 fs::write(&path, "schema_version = 99\n").expect("write");
1395 assert!(matches!(
1396 Config::load_from_path(&path).expect_err("v99 should fail"),
1397 ConfigError::UnsupportedSchemaVersion { found: 99, .. }
1398 ));
1399
1400 fs::write(&path, "schema_version = 1\n").expect("write");
1401 assert!(
1402 Config::load_from_path(&path).is_ok(),
1403 "v1 should still load"
1404 );
1405 }
1406
1407 #[test]
1408 fn set_gesture_direction_upgrades_single_to_gesture() {
1409 let mut cfg = Config::default();
1410 cfg.set_binding(
1412 "2b042",
1413 ButtonId::Back,
1414 Binding::Single(Action::BrowserBack),
1415 );
1416 cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
1417
1418 match cfg.bindings_for("2b042").get(&ButtonId::Back) {
1419 Some(Binding::Gesture(map)) => {
1420 assert_eq!(
1422 map.get(&GestureDirection::Click),
1423 Some(&Action::BrowserBack)
1424 );
1425 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1426 }
1427 other => panic!("expected Gesture after upgrade, got {other:?}"),
1428 }
1429 }
1430
1431 #[test]
1432 fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
1433 let mut cfg = Config::default();
1437 cfg.set_gesture_direction(
1438 "2b042",
1439 ButtonId::GestureButton,
1440 GestureDirection::Up,
1441 Action::Copy,
1442 );
1443
1444 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1445 Some(Binding::Gesture(map)) => {
1446 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1447 assert_eq!(
1448 map.get(&GestureDirection::Click),
1449 Some(&crate::binding::default_gesture_binding(
1450 GestureDirection::Click
1451 )),
1452 "a fresh gesture button must seed a Click from its default"
1453 );
1454 }
1455 other => panic!("expected Gesture, got {other:?}"),
1456 }
1457 }
1458
1459 #[test]
1460 fn gesture_owner_defaults_to_thumb_pad_yields_to_oshook_and_can_be_off() {
1461 let mut cfg = Config::default();
1462 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1464
1465 cfg.set_gesture_direction(
1467 "2b042",
1468 ButtonId::GestureButton,
1469 GestureDirection::Up,
1470 Action::MissionControl,
1471 );
1472 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1473
1474 cfg.set_binding(
1476 "2b042",
1477 ButtonId::Forward,
1478 Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
1479 );
1480 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
1481
1482 let mut off = Config::default();
1484 off.disable_gestures("2b042");
1485 assert_eq!(off.gesture_owner("2b042"), None);
1486 }
1487
1488 #[test]
1489 fn set_gesture_owner_records_owner_without_destroying_other_maps() {
1490 let mut cfg = Config::default();
1491 cfg.set_gesture_direction(
1493 "2b042",
1494 ButtonId::GestureButton,
1495 GestureDirection::Up,
1496 Action::Copy,
1497 );
1498 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1499
1500 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
1503 cfg.set_gesture_owner("2b042", ButtonId::Back);
1504 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
1505
1506 let bindings = cfg.bindings_for("2b042");
1507 match bindings.get(&ButtonId::Back) {
1510 Some(Binding::Gesture(map)) => {
1511 assert_eq!(
1512 map.get(&GestureDirection::Click),
1513 Some(&Action::BrowserBack)
1514 );
1515 assert_eq!(
1516 map.get(&GestureDirection::Up),
1517 Some(&default_gesture_binding(GestureDirection::Up)),
1518 "a promoted button gets full default arms"
1519 );
1520 }
1521 other => panic!("expected Back to be a gesture binding, got {other:?}"),
1522 }
1523 match bindings.get(&ButtonId::GestureButton) {
1525 Some(Binding::Gesture(map)) => {
1526 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1527 }
1528 other => panic!("expected the thumb pad map preserved, got {other:?}"),
1529 }
1530
1531 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1534 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1535 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1536 Some(Binding::Gesture(map)) => {
1537 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1538 }
1539 other => panic!("expected preserved gesture map, got {other:?}"),
1540 }
1541 }
1542
1543 #[test]
1544 fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
1545 let mut cfg = Config::default();
1546 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1548 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1549 Some(Binding::Gesture(map)) => {
1550 for dir in GestureDirection::ALL {
1551 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1552 }
1553 }
1554 other => panic!("expected full default gesture map, got {other:?}"),
1555 }
1556
1557 cfg.set_gesture_owner("2b042", ButtonId::Forward);
1561 match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
1562 Some(Binding::Gesture(map)) => {
1563 assert_eq!(
1564 map.get(&GestureDirection::Click),
1565 Some(&default_binding(ButtonId::Forward))
1566 );
1567 for dir in [
1568 GestureDirection::Up,
1569 GestureDirection::Down,
1570 GestureDirection::Left,
1571 GestureDirection::Right,
1572 ] {
1573 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1574 }
1575 }
1576 other => panic!("expected full gesture map for Forward, got {other:?}"),
1577 }
1578 }
1579
1580 #[test]
1581 fn disable_gestures_turns_off_without_destroying_maps() {
1582 let mut cfg = Config::default();
1583 cfg.set_gesture_direction(
1584 "2b042",
1585 ButtonId::GestureButton,
1586 GestureDirection::Up,
1587 Action::Copy,
1588 );
1589 cfg.disable_gestures("2b042");
1590 assert_eq!(cfg.gesture_owner("2b042"), None);
1593 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1594 Some(Binding::Gesture(map)) => {
1595 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1596 }
1597 other => panic!("expected the gesture map preserved while off, got {other:?}"),
1598 }
1599 }
1600
1601 #[test]
1602 fn gesture_owner_field_roundtrips_as_a_scalar() {
1603 let mut cfg = Config::default();
1604 cfg.set_gesture_owner("2b042", ButtonId::Back); cfg.disable_gestures("4082d"); let parsed = write_and_read(&cfg);
1608 assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
1609 assert_eq!(parsed.gesture_owner("4082d"), None);
1610
1611 let body = toml::to_string_pretty(&cfg).expect("serialize");
1614 assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
1615 assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
1616 }
1617
1618 #[test]
1619 fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
1620 let toml = "\
1624schema_version = 2
1625
1626[devices.2b042]
1627gesture_owner = \"bogus\"
1628
1629[devices.2b042.bindings]
1630Back = \"Copy\"
1631";
1632 let dir = tempfile::tempdir().expect("tempdir");
1633 let path = dir.path().join("config.toml");
1634 fs::write(&path, toml).expect("write");
1635
1636 let cfg =
1637 Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
1638 assert_eq!(
1640 cfg.bindings_for("2b042").get(&ButtonId::Back),
1641 Some(&Binding::Single(Action::Copy))
1642 );
1643 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1645 }
1646}