1use std::{
10 collections::BTreeMap,
11 fs, io,
12 path::{Path, PathBuf},
13};
14
15use atomic_write_file::AtomicWriteFile;
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19mod device;
20mod settings;
21
22pub use device::{DeviceConfig, DeviceIdentity};
23pub use settings::{
24 AppSettings, Appearance, DEFAULT_THUMBWHEEL_SENSITIVITY, GestureOwner, Lighting,
25 MAX_THUMBWHEEL_SENSITIVITY, MIN_THUMBWHEEL_SENSITIVITY, SMARTSHIFT_AUTO_DISENGAGE_DEFAULT,
26 SMARTSHIFT_MIN_AUTO_DISENGAGE, SmartShift, WheelMode,
27};
28
29use crate::binding::{Action, Binding, ButtonId, GestureDirection, default_binding_for};
30use crate::paths::{self, PathsError};
31
32pub const SCHEMA_VERSION: u32 = 3;
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Config {
50 pub schema_version: u32,
54 #[serde(default, skip_serializing_if = "AppSettings::is_default")]
56 pub app_settings: AppSettings,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub selected_device: Option<String>,
62 #[serde(default)]
66 pub devices: BTreeMap<String, DeviceConfig>,
67}
68
69impl Default for Config {
70 fn default() -> Self {
71 Self {
72 schema_version: SCHEMA_VERSION,
73 app_settings: AppSettings::default(),
74 selected_device: None,
75 devices: BTreeMap::new(),
76 }
77 }
78}
79
80#[derive(Debug, Error)]
83pub enum ConfigError {
84 #[error("could not resolve config path")]
87 Path(#[from] PathsError),
88 #[error("could not read config at {path}")]
90 Read {
91 path: PathBuf,
93 #[source]
95 source: io::Error,
96 },
97 #[error("could not parse config at {path}")]
99 Parse {
100 path: PathBuf,
102 #[source]
104 source: toml::de::Error,
105 },
106 #[error("could not write config at {path}")]
108 Write {
109 path: PathBuf,
111 #[source]
113 source: io::Error,
114 },
115 #[error("could not serialize config")]
119 Serialize(#[from] toml::ser::Error),
120 #[error("config at {path} has unsupported schema_version {found}")]
124 UnsupportedSchemaVersion {
125 path: PathBuf,
127 found: u32,
129 },
130}
131
132#[allow(
133 clippy::result_large_err,
134 reason = "Config I/O keeps rich parse/write context and is not a hot path"
135)]
136impl Config {
137 pub fn load_or_default() -> Result<Self, ConfigError> {
140 Self::load_from_path(&paths::config_path()?)
141 }
142
143 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
146 match fs::read_to_string(path) {
147 Ok(text) => {
148 let mut config: Self =
149 toml::from_str(&text).map_err(|source| ConfigError::Parse {
150 path: path.to_path_buf(),
151 source,
152 })?;
153 if config.schema_version > SCHEMA_VERSION {
159 return Err(ConfigError::UnsupportedSchemaVersion {
160 path: path.to_path_buf(),
161 found: config.schema_version,
162 });
163 }
164 config.schema_version = SCHEMA_VERSION;
168 Ok(config)
169 }
170 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
171 Err(source) => Err(ConfigError::Read {
172 path: path.to_path_buf(),
173 source,
174 }),
175 }
176 }
177
178 pub fn save_atomic(&self) -> Result<(), ConfigError> {
182 self.save_to_path(&paths::config_path()?)
183 }
184
185 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
187 if let Some(parent) = path.parent() {
188 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
189 path: path.to_path_buf(),
190 source,
191 })?;
192 }
193 let body = toml::to_string_pretty(self)?;
194 write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
195 path: path.to_path_buf(),
196 source,
197 })
198 }
199
200 #[must_use]
203 pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
204 self.devices
205 .get(device_key)
206 .map(|d| d.bindings.clone())
207 .unwrap_or_default()
208 }
209
210 pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
215 self.devices
216 .entry(device_key.to_string())
217 .or_default()
218 .bindings
219 .insert(button, binding);
220 }
221
222 #[must_use]
227 pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
228 match self
229 .devices
230 .get(device_key)
231 .and_then(|d| d.bindings.get(&ButtonId::GestureButton))
232 {
233 Some(Binding::Gesture(map)) => map.clone(),
234 _ => BTreeMap::new(),
235 }
236 }
237
238 pub fn set_gesture_direction(
248 &mut self,
249 device_key: &str,
250 button: ButtonId,
251 direction: GestureDirection,
252 action: Action,
253 ) {
254 if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
255 map.insert(direction, action);
256 }
257 }
258
259 fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
267 let entry = self
268 .devices
269 .entry(device_key.to_string())
270 .or_default()
271 .bindings
272 .entry(button)
273 .or_insert_with(|| default_binding_for(button));
274 entry.upgrade_to_gesture();
275 entry
276 }
277
278 #[must_use]
287 pub fn gesture_owner(&self, device_key: &str) -> Option<ButtonId> {
288 let Some(device) = self.devices.get(device_key) else {
289 return Some(ButtonId::GestureButton);
291 };
292 match device.gesture_owner {
293 Some(GestureOwner::Off) => None,
294 Some(GestureOwner::Button(id)) => Some(id),
295 None => Self::infer_gesture_owner(&device.bindings),
296 }
297 }
298
299 fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
304 if let Some((id, _)) = bindings
306 .iter()
307 .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
308 {
309 return Some(*id);
310 }
311 if matches!(
313 bindings.get(&ButtonId::GestureButton),
314 Some(Binding::Single(_))
315 ) {
316 return None;
317 }
318 Some(ButtonId::GestureButton)
320 }
321
322 pub fn set_gesture_owner(&mut self, device_key: &str, button: ButtonId) {
335 self.devices
336 .entry(device_key.to_string())
337 .or_default()
338 .gesture_owner = Some(GestureOwner::Button(button));
339 self.ensure_gesture_binding(device_key, button)
340 .fill_gesture_defaults();
341 }
342
343 pub fn disable_gestures(&mut self, device_key: &str) {
347 self.devices
348 .entry(device_key.to_string())
349 .or_default()
350 .gesture_owner = Some(GestureOwner::Off);
351 }
352
353 #[must_use]
361 pub fn effective_bindings(
362 &self,
363 device_key: &str,
364 bundle_id: Option<&str>,
365 ) -> BTreeMap<ButtonId, Binding> {
366 let Some(device) = self.devices.get(device_key) else {
367 return BTreeMap::new();
368 };
369 let mut out = device.bindings.clone();
370 if let Some(bid) = bundle_id
371 && let Some(overlay) = device.per_app_bindings.get(bid)
372 {
373 for (k, v) in overlay {
374 out.insert(*k, Binding::Single(v.clone()));
375 }
376 }
377 out
378 }
379
380 pub fn set_per_app_binding(
384 &mut self,
385 device_key: &str,
386 bundle_id: &str,
387 button: ButtonId,
388 action: Option<Action>,
389 ) {
390 let entry = self
391 .devices
392 .entry(device_key.to_string())
393 .or_default()
394 .per_app_bindings
395 .entry(bundle_id.to_string())
396 .or_default();
397 match action {
398 Some(a) => {
399 entry.insert(button, a);
400 }
401 None => {
402 entry.remove(&button);
403 }
404 }
405 if let Some(d) = self.devices.get_mut(device_key) {
406 d.per_app_bindings.retain(|_, m| !m.is_empty());
407 }
408 }
409
410 #[must_use]
412 pub fn selected_device(&self) -> Option<&str> {
413 self.selected_device.as_deref()
414 }
415
416 pub fn set_selected_device(&mut self, key: Option<String>) {
419 self.selected_device = key;
420 }
421
422 #[must_use]
425 pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
426 self.devices
427 .get(device_key)
428 .map(|d| d.dpi_presets.clone())
429 .unwrap_or_default()
430 }
431
432 pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
436 self.devices
437 .entry(device_key.to_string())
438 .or_default()
439 .dpi_presets = presets;
440 }
441
442 #[must_use]
446 pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
447 self.devices
448 .get(device_key)
449 .and_then(|d| d.identity.as_ref())
450 }
451
452 pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
455 self.devices
456 .entry(device_key.to_string())
457 .or_default()
458 .identity = Some(identity);
459 }
460
461 #[must_use]
466 pub fn has_app_override(&self, device_key: &str, app: &str) -> bool {
467 self.devices.get(device_key).is_some_and(|d| {
468 d.per_app_bindings
469 .get(app)
470 .is_some_and(|overlay| !overlay.is_empty())
471 })
472 }
473
474 pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
478 self.devices
479 .iter()
480 .filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
481 }
482
483 #[must_use]
485 pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
486 self.devices
487 .get(device_key)
488 .and_then(|d| d.lighting.clone())
489 }
490
491 pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
493 self.devices
494 .entry(device_key.to_string())
495 .or_default()
496 .lighting = Some(lighting);
497 }
498
499 #[must_use]
501 pub fn dpi(&self, device_key: &str) -> Option<u32> {
502 self.devices.get(device_key).and_then(|d| d.dpi)
503 }
504
505 pub fn set_dpi(&mut self, device_key: &str, dpi: u32) {
508 self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
509 }
510
511 #[must_use]
513 pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
514 self.devices.get(device_key).and_then(|d| d.smartshift)
515 }
516
517 pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
520 self.devices
521 .entry(device_key.to_string())
522 .or_default()
523 .smartshift = Some(smartshift);
524 }
525
526 #[must_use]
529 pub fn invert_scroll(&self, device_key: &str) -> bool {
530 self.devices
531 .get(device_key)
532 .is_some_and(|d| d.invert_scroll)
533 }
534
535 pub fn set_invert_scroll(&mut self, device_key: &str, invert: bool) {
538 self.devices
539 .entry(device_key.to_string())
540 .or_default()
541 .invert_scroll = invert;
542 }
543}
544
545fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
548 #[cfg_attr(
549 not(unix),
550 expect(unused_mut, reason = "only the unix path mutates the options")
551 )]
552 let mut options = AtomicWriteFile::options();
553 #[cfg(unix)]
554 {
555 use atomic_write_file::unix::OpenOptionsExt as _;
556 use std::os::unix::fs::OpenOptionsExt as _;
557 options.preserve_mode(false).mode(0o600);
559 }
560 let mut file = options.open(path)?;
561 io::Write::write_all(&mut file, bytes)?;
562 file.commit()
563}
564
565#[cfg(test)]
566#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
567mod tests {
568 use std::assert_matches;
569
570 use super::*;
571 use crate::binding::{default_binding, default_gesture_binding};
572
573 fn write_and_read(config: &Config) -> Config {
574 let dir = tempfile::tempdir().expect("tempdir");
575 let path = dir.path().join("config.toml");
576 config.save_to_path(&path).expect("save");
577 Config::load_from_path(&path).expect("load")
578 }
579
580 #[test]
581 fn missing_file_yields_default() {
582 let dir = tempfile::tempdir().expect("tempdir");
583 let path = dir.path().join("nonexistent.toml");
584 let cfg = Config::load_from_path(&path).expect("load");
585 assert_eq!(cfg.schema_version, SCHEMA_VERSION);
586 assert!(cfg.devices.is_empty());
587 }
588
589 #[test]
590 fn lighting_roundtrips_per_device() {
591 let mut cfg = Config::default();
592 cfg.set_lighting(
593 "g513",
594 Lighting {
595 enabled: true,
596 color: "00aabb".parse().expect("valid hex"),
597 brightness: 75,
598 },
599 );
600 let restored = write_and_read(&cfg);
601 assert_eq!(
602 restored.lighting("g513"),
603 Some(Lighting {
604 enabled: true,
605 color: "00aabb".parse().expect("valid hex"),
606 brightness: 75,
607 })
608 );
609 assert_eq!(restored.lighting("absent"), None);
610 }
611
612 #[test]
613 fn unparseable_lighting_color_falls_back_to_white() {
614 let cfg: Config = toml::from_str(
615 r#"
616 schema_version = 3
617 [devices.g513.lighting]
618 enabled = true
619 color = "red"
620 brightness = 50
621 "#,
622 )
623 .expect("config with a bad color still loads");
624 assert_eq!(
625 cfg.lighting("g513").map(|l| l.color),
626 Some(crate::color::Rgb::WHITE)
627 );
628 }
629
630 #[test]
631 fn hash_prefixed_lighting_color_migrates_to_canonical_hex() {
632 let dir = tempfile::tempdir().expect("tempdir");
633 let path = dir.path().join("config.toml");
634 fs::write(
635 &path,
636 r##"
637 schema_version = 3
638 [devices.g513.lighting]
639 enabled = true
640 color = "#ff0000"
641 brightness = 50
642 "##,
643 )
644 .expect("write config");
645
646 let cfg = Config::load_from_path(&path).expect("load hash-prefixed color");
647 assert_eq!(
648 cfg.lighting("g513").map(|lighting| lighting.color),
649 Some(crate::color::Rgb::new(0xff, 0x00, 0x00))
650 );
651
652 cfg.save_to_path(&path).expect("save canonical color");
653 let saved = fs::read_to_string(path).expect("read saved config");
654 assert!(saved.contains("color = \"ff0000\""));
655 assert!(!saved.contains("color = \"#"));
656 }
657
658 #[test]
659 fn dpi_roundtrips_per_device() {
660 let mut cfg = Config::default();
661 cfg.set_dpi("2b042", 1600);
662 let restored = write_and_read(&cfg);
663 assert_eq!(restored.dpi("2b042"), Some(1600));
664 assert_eq!(restored.dpi("absent"), None);
665 }
666
667 #[test]
668 fn smartshift_roundtrips_per_device() {
669 let mut cfg = Config::default();
670 cfg.set_smartshift(
671 "2b042",
672 SmartShift {
673 mode: WheelMode::Ratchet,
674 auto_disengage: 16,
675 tunable_torque: 30,
676 },
677 );
678 let restored = write_and_read(&cfg);
679 assert_eq!(
680 restored.smartshift("2b042"),
681 Some(SmartShift {
682 mode: WheelMode::Ratchet,
683 auto_disengage: 16,
684 tunable_torque: 30,
685 })
686 );
687 assert_eq!(restored.smartshift("absent"), None);
688 }
689
690 #[test]
691 fn invert_scroll_roundtrips_per_device() {
692 let mut cfg = Config::default();
693 assert!(!cfg.invert_scroll("2b042"));
695 cfg.set_invert_scroll("2b042", true);
696 let restored = write_and_read(&cfg);
697 assert!(restored.invert_scroll("2b042"));
698 assert!(!restored.invert_scroll("absent"));
699 }
700
701 #[test]
702 fn default_invert_scroll_is_omitted_from_toml() {
703 let mut cfg = Config::default();
706 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
707 cfg.set_invert_scroll("2b042", false);
708 let body = toml::to_string_pretty(&cfg).expect("serialize");
709 assert!(
710 !body.contains("invert_scroll"),
711 "default invert_scroll should be omitted: {body}"
712 );
713 }
714
715 #[test]
716 fn bindings_roundtrip_per_device() {
717 let mut cfg = Config::default();
718 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
719 cfg.set_binding(
720 "2b042",
721 ButtonId::DpiToggle,
722 Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
723 modifiers: crate::binding::KeyCombo::MOD_CMD,
724 key_code: 0x23, display: "⌘P".into(),
726 })),
727 );
728 cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
729
730 let parsed = write_and_read(&cfg);
731
732 let a = parsed.bindings_for("2b042");
734 assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
735 assert_eq!(
736 a.get(&ButtonId::DpiToggle),
737 Some(&Binding::Single(Action::CustomShortcut(
738 crate::binding::KeyCombo {
739 modifiers: crate::binding::KeyCombo::MOD_CMD,
740 key_code: 0x23,
741 display: "⌘P".into(),
742 }
743 )))
744 );
745
746 let b = parsed.bindings_for("4082d");
747 assert_eq!(
748 b.get(&ButtonId::Back),
749 Some(&Binding::Single(Action::Paste))
750 );
751 assert_eq!(b.len(), 1, "device b should only see its own bindings");
752
753 assert!(parsed.bindings_for("deadbeef").is_empty());
755 }
756
757 #[test]
758 fn human_readable_toml_layout() {
759 let mut cfg = Config::default();
760 cfg.set_binding(
761 "2b042",
762 ButtonId::Back,
763 Binding::Single(Action::BrowserBack),
764 );
765 let body = toml::to_string_pretty(&cfg).expect("serialize");
766
767 assert!(body.contains("schema_version = 3"), "got: {body}");
771 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
772 assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
775 }
776
777 #[test]
778 fn dpi_presets_roundtrip_per_device() {
779 let mut cfg = Config::default();
780 cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
781 cfg.set_dpi_presets("4082d", vec![400, 1600]);
782
783 let parsed = write_and_read(&cfg);
784
785 assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
786 assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
787 assert!(parsed.dpi_presets("unknown").is_empty());
788 }
789
790 #[test]
791 fn empty_dpi_presets_skip_serialization() {
792 let mut cfg = Config::default();
793 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
795 cfg.set_dpi_presets("2b042", vec![800]);
796 cfg.set_dpi_presets("2b042", vec![]); let body = toml::to_string_pretty(&cfg).expect("serialize");
799 assert!(
800 !body.contains("dpi_presets"),
801 "empty dpi_presets should be omitted: {body}"
802 );
803 }
804
805 #[test]
806 fn device_identity_roundtrips_and_is_iterable() {
807 use crate::device::{Capabilities, DeviceKind};
808
809 let mut cfg = Config::default();
810 let mouse = DeviceIdentity {
811 display_name: "MX Master 3S".to_string(),
812 model_info: None,
813 codename: None,
814 kind: DeviceKind::Mouse,
815 capabilities: Capabilities {
816 buttons: true,
817 pointer: true,
818 lighting: false,
819 scroll_inversion: false,
820 },
821 };
822 cfg.set_device_identity("2b034", mouse.clone());
823 cfg.set_binding(
825 "2b034",
826 ButtonId::Back,
827 Binding::Single(Action::BrowserBack),
828 );
829
830 let parsed = write_and_read(&cfg);
831 assert_eq!(parsed.device_identity("2b034"), Some(&mouse));
832 assert_eq!(parsed.device_identity("absent"), None);
833 assert_eq!(
834 parsed.bindings_for("2b034").get(&ButtonId::Back),
835 Some(&Binding::Single(Action::BrowserBack)),
836 "identity must coexist with bindings on the same device block"
837 );
838 assert_eq!(
839 parsed.known_identities().collect::<Vec<_>>(),
840 vec![("2b034", &mouse)]
841 );
842 }
843
844 #[test]
845 fn selected_device_roundtrips() {
846 let mut cfg = Config::default();
847 assert_eq!(cfg.selected_device(), None);
848 cfg.set_selected_device(Some("2b042".into()));
849 let parsed = write_and_read(&cfg);
850 assert_eq!(parsed.selected_device(), Some("2b042"));
851 }
852
853 #[test]
854 fn per_app_overlay_takes_precedence() {
855 let mut cfg = Config::default();
856 cfg.set_binding(
857 "2b042",
858 ButtonId::Back,
859 Binding::Single(Action::BrowserBack),
860 );
861 cfg.set_binding(
862 "2b042",
863 ButtonId::Forward,
864 Binding::Single(Action::BrowserForward),
865 );
866 cfg.set_per_app_binding(
867 "2b042",
868 "com.microsoft.VSCode",
869 ButtonId::Back,
870 Some(Action::Undo),
871 );
872
873 let global = cfg.effective_bindings("2b042", None);
875 assert_eq!(
876 global.get(&ButtonId::Back),
877 Some(&Binding::Single(Action::BrowserBack))
878 );
879 assert_eq!(
880 global.get(&ButtonId::Forward),
881 Some(&Binding::Single(Action::BrowserForward))
882 );
883
884 let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
886 assert_eq!(
887 vscode.get(&ButtonId::Back),
888 Some(&Binding::Single(Action::Undo))
889 );
890 assert_eq!(
891 vscode.get(&ButtonId::Forward),
892 Some(&Binding::Single(Action::BrowserForward))
893 );
894
895 let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
897 assert_eq!(
898 other.get(&ButtonId::Back),
899 Some(&Binding::Single(Action::BrowserBack))
900 );
901 }
902
903 #[test]
904 fn per_app_binding_removal_prunes_empty_app() {
905 let mut cfg = Config::default();
906 cfg.set_per_app_binding(
907 "2b042",
908 "com.example.App",
909 ButtonId::Back,
910 Some(Action::Copy),
911 );
912 cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
913 assert!(
914 cfg.devices["2b042"].per_app_bindings.is_empty(),
915 "removing last override should prune the app entry"
916 );
917 }
918
919 #[test]
920 fn app_settings_default_omits_block() {
921 let cfg = Config::default();
922 let body = toml::to_string_pretty(&cfg).expect("serialize");
923 assert!(
924 !body.contains("app_settings"),
925 "default app_settings should be omitted: {body}"
926 );
927 }
928
929 #[test]
930 fn app_settings_launch_at_login_roundtrips() {
931 let mut cfg = Config::default();
932 cfg.app_settings.launch_at_login = true;
933 let parsed = write_and_read(&cfg);
934 assert!(parsed.app_settings.launch_at_login);
935 }
936
937 #[test]
938 fn cleared_selected_device_omits_field() {
939 let mut cfg = Config::default();
940 cfg.set_selected_device(Some("2b042".into()));
941 cfg.set_selected_device(None);
942 let body = toml::to_string_pretty(&cfg).expect("serialize");
943 assert!(
944 !body.contains("selected_device"),
945 "cleared selection should not appear: {body}"
946 );
947 }
948
949 #[test]
950 fn empty_device_block_is_skipped_in_output() {
951 let mut cfg = Config::default();
954 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
955 cfg.devices
956 .get_mut("2b042")
957 .expect("entry")
958 .bindings
959 .clear();
960 let body = toml::to_string_pretty(&cfg).expect("serialize");
961 assert!(
962 !body.contains("Back"),
963 "cleared bindings should not appear: {body}"
964 );
965 }
966
967 #[test]
968 fn migrates_v1_button_and_gesture_bindings() {
969 let v1 = "\
971schema_version = 1
972
973[devices.2b042.button_bindings]
974Back = \"BrowserBack\"
975
976[devices.2b042.gesture_bindings]
977Up = \"Copy\"
978Click = \"Paste\"
979";
980 let dir = tempfile::tempdir().expect("tempdir");
981 let path = dir.path().join("config.toml");
982 fs::write(&path, v1).expect("write");
983
984 let cfg = Config::load_from_path(&path).expect("load v1");
986 let bindings = cfg.bindings_for("2b042");
987 assert_eq!(
988 bindings.get(&ButtonId::Back),
989 Some(&Binding::Single(Action::BrowserBack))
990 );
991 let mut gesture = BTreeMap::new();
992 gesture.insert(GestureDirection::Up, Action::Copy);
993 gesture.insert(GestureDirection::Click, Action::Paste);
994 assert_eq!(
995 bindings.get(&ButtonId::GestureButton),
996 Some(&Binding::Gesture(gesture))
997 );
998
999 let body = toml::to_string_pretty(&cfg).expect("serialize");
1002 assert!(body.contains("schema_version = 3"), "got: {body}");
1003 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1004 assert!(!body.contains("button_bindings"), "got: {body}");
1005 assert!(!body.contains("gesture_bindings"), "got: {body}");
1006 }
1007
1008 #[test]
1009 fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
1010 let v1 = "\
1015schema_version = 1
1016
1017[devices.2b042.button_bindings]
1018GestureButton = \"MissionControl\"
1019
1020[devices.2b042.gesture_bindings]
1021Up = \"Copy\"
1022Down = \"Paste\"
1023";
1024 let dir = tempfile::tempdir().expect("tempdir");
1025 let path = dir.path().join("config.toml");
1026 fs::write(&path, v1).expect("write");
1027
1028 let cfg = Config::load_from_path(&path).expect("load v1");
1029 let mut gesture = BTreeMap::new();
1030 gesture.insert(GestureDirection::Up, Action::Copy);
1031 gesture.insert(GestureDirection::Down, Action::Paste);
1032 assert_eq!(
1033 cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
1034 Some(&Binding::Gesture(gesture)),
1035 "gesture map must win over the legacy single GestureButton entry"
1036 );
1037 }
1038
1039 #[test]
1040 fn migration_drops_vestigial_lone_gesture_button_single() {
1041 let v1 = "\
1048schema_version = 1
1049
1050[devices.2b042.button_bindings]
1051GestureButton = \"MissionControl\"
1052Back = \"BrowserBack\"
1053";
1054 let dir = tempfile::tempdir().expect("tempdir");
1055 let path = dir.path().join("config.toml");
1056 fs::write(&path, v1).expect("write");
1057
1058 let bindings = Config::load_from_path(&path)
1059 .expect("load v1")
1060 .bindings_for("2b042");
1061 assert_eq!(
1063 bindings.get(&ButtonId::Back),
1064 Some(&Binding::Single(Action::BrowserBack))
1065 );
1066 assert_eq!(bindings.get(&ButtonId::GestureButton), None);
1069 }
1070
1071 #[test]
1072 fn rejects_newer_schema_version_but_accepts_v1() {
1073 let dir = tempfile::tempdir().expect("tempdir");
1076 let path = dir.path().join("config.toml");
1077 fs::write(&path, "schema_version = 99\n").expect("write");
1078 assert_matches!(
1079 Config::load_from_path(&path).expect_err("v99 should fail"),
1080 ConfigError::UnsupportedSchemaVersion { found: 99, .. }
1081 );
1082
1083 fs::write(&path, "schema_version = 1\n").expect("write");
1084 assert!(
1085 Config::load_from_path(&path).is_ok(),
1086 "v1 should still load"
1087 );
1088 }
1089
1090 #[test]
1091 fn set_gesture_direction_upgrades_single_to_gesture() {
1092 let mut cfg = Config::default();
1093 cfg.set_binding(
1095 "2b042",
1096 ButtonId::Back,
1097 Binding::Single(Action::BrowserBack),
1098 );
1099 cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
1100
1101 match cfg.bindings_for("2b042").get(&ButtonId::Back) {
1102 Some(Binding::Gesture(map)) => {
1103 assert_eq!(
1105 map.get(&GestureDirection::Click),
1106 Some(&Action::BrowserBack)
1107 );
1108 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1109 }
1110 other => panic!("expected Gesture after upgrade, got {other:?}"),
1111 }
1112 }
1113
1114 #[test]
1115 fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
1116 let mut cfg = Config::default();
1120 cfg.set_gesture_direction(
1121 "2b042",
1122 ButtonId::GestureButton,
1123 GestureDirection::Up,
1124 Action::Copy,
1125 );
1126
1127 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1128 Some(Binding::Gesture(map)) => {
1129 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1130 assert_eq!(
1131 map.get(&GestureDirection::Click),
1132 Some(&crate::binding::default_gesture_binding(
1133 GestureDirection::Click
1134 )),
1135 "a fresh gesture button must seed a Click from its default"
1136 );
1137 }
1138 other => panic!("expected Gesture, got {other:?}"),
1139 }
1140 }
1141
1142 #[test]
1143 fn gesture_owner_defaults_to_hidpp_button_yields_to_oshook_and_can_be_off() {
1144 let mut cfg = Config::default();
1145 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1147
1148 cfg.set_gesture_direction(
1150 "2b042",
1151 ButtonId::GestureButton,
1152 GestureDirection::Up,
1153 Action::MissionControl,
1154 );
1155 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1156
1157 cfg.set_binding(
1159 "2b042",
1160 ButtonId::Forward,
1161 Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
1162 );
1163 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
1164
1165 let mut off = Config::default();
1167 off.disable_gestures("2b042");
1168 assert_eq!(off.gesture_owner("2b042"), None);
1169 }
1170
1171 #[test]
1172 fn set_gesture_owner_records_owner_without_destroying_other_maps() {
1173 let mut cfg = Config::default();
1174 cfg.set_gesture_direction(
1176 "2b042",
1177 ButtonId::GestureButton,
1178 GestureDirection::Up,
1179 Action::Copy,
1180 );
1181 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1182
1183 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
1186 cfg.set_gesture_owner("2b042", ButtonId::Back);
1187 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
1188
1189 let bindings = cfg.bindings_for("2b042");
1190 match bindings.get(&ButtonId::Back) {
1193 Some(Binding::Gesture(map)) => {
1194 assert_eq!(
1195 map.get(&GestureDirection::Click),
1196 Some(&Action::BrowserBack)
1197 );
1198 assert_eq!(
1199 map.get(&GestureDirection::Up),
1200 Some(&default_gesture_binding(GestureDirection::Up)),
1201 "a promoted button gets full default arms"
1202 );
1203 }
1204 other => panic!("expected Back to be a gesture binding, got {other:?}"),
1205 }
1206 match bindings.get(&ButtonId::GestureButton) {
1208 Some(Binding::Gesture(map)) => {
1209 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1210 }
1211 other => panic!("expected the HID++ gesture button map preserved, got {other:?}"),
1212 }
1213
1214 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1217 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1218 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1219 Some(Binding::Gesture(map)) => {
1220 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1221 }
1222 other => panic!("expected preserved gesture map, got {other:?}"),
1223 }
1224 }
1225
1226 #[test]
1227 fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
1228 let mut cfg = Config::default();
1229 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1231 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1232 Some(Binding::Gesture(map)) => {
1233 for dir in GestureDirection::ALL {
1234 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1235 }
1236 }
1237 other => panic!("expected full default gesture map, got {other:?}"),
1238 }
1239
1240 cfg.set_gesture_owner("2b042", ButtonId::Forward);
1244 match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
1245 Some(Binding::Gesture(map)) => {
1246 assert_eq!(
1247 map.get(&GestureDirection::Click),
1248 Some(&default_binding(ButtonId::Forward))
1249 );
1250 for dir in [
1251 GestureDirection::Up,
1252 GestureDirection::Down,
1253 GestureDirection::Left,
1254 GestureDirection::Right,
1255 ] {
1256 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1257 }
1258 }
1259 other => panic!("expected full gesture map for Forward, got {other:?}"),
1260 }
1261 }
1262
1263 #[test]
1264 fn disable_gestures_turns_off_without_destroying_maps() {
1265 let mut cfg = Config::default();
1266 cfg.set_gesture_direction(
1267 "2b042",
1268 ButtonId::GestureButton,
1269 GestureDirection::Up,
1270 Action::Copy,
1271 );
1272 cfg.disable_gestures("2b042");
1273 assert_eq!(cfg.gesture_owner("2b042"), None);
1276 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1277 Some(Binding::Gesture(map)) => {
1278 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1279 }
1280 other => panic!("expected the gesture map preserved while off, got {other:?}"),
1281 }
1282 }
1283
1284 #[test]
1285 fn gesture_owner_field_roundtrips_as_a_scalar() {
1286 let mut cfg = Config::default();
1287 cfg.set_gesture_owner("2b042", ButtonId::Back); cfg.disable_gestures("4082d"); let parsed = write_and_read(&cfg);
1291 assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
1292 assert_eq!(parsed.gesture_owner("4082d"), None);
1293
1294 let body = toml::to_string_pretty(&cfg).expect("serialize");
1297 assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
1298 assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
1299 }
1300
1301 #[test]
1302 fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
1303 let toml = "\
1307schema_version = 2
1308
1309[devices.2b042]
1310gesture_owner = \"bogus\"
1311
1312[devices.2b042.bindings]
1313Back = \"Copy\"
1314";
1315 let dir = tempfile::tempdir().expect("tempdir");
1316 let path = dir.path().join("config.toml");
1317 fs::write(&path, toml).expect("write");
1318
1319 let cfg =
1320 Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
1321 assert_eq!(
1323 cfg.bindings_for("2b042").get(&ButtonId::Back),
1324 Some(&Binding::Single(Action::Copy))
1325 );
1326 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1328 }
1329}