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}
375
376#[derive(Deserialize)]
381struct RawDeviceConfig {
382 #[serde(default, deserialize_with = "deserialize_gesture_owner")]
387 gesture_owner: Option<GestureOwner>,
388 #[serde(default)]
389 identity: Option<DeviceIdentity>,
390 #[serde(default)]
392 bindings: BTreeMap<ButtonId, Binding>,
393 #[serde(default)]
395 button_bindings: BTreeMap<ButtonId, Action>,
396 #[serde(default)]
398 gesture_bindings: BTreeMap<GestureDirection, Action>,
399 #[serde(default)]
400 per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
401 #[serde(default)]
402 dpi_presets: Vec<u32>,
403 #[serde(default)]
404 dpi: Option<u32>,
405 #[serde(default)]
406 lighting: Option<Lighting>,
407 #[serde(default)]
408 smartshift: Option<SmartShift>,
409}
410
411impl From<RawDeviceConfig> for DeviceConfig {
412 fn from(raw: RawDeviceConfig) -> Self {
413 let mut bindings = raw.bindings; if !raw.gesture_bindings.is_empty() {
421 bindings
422 .entry(ButtonId::GestureButton)
423 .or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
424 }
425 for (button, action) in raw.button_bindings {
426 if button == ButtonId::GestureButton {
437 continue;
438 }
439 bindings.entry(button).or_insert(Binding::Single(action));
440 }
441
442 DeviceConfig {
443 gesture_owner: raw.gesture_owner,
444 identity: raw.identity,
445 bindings,
446 per_app_bindings: raw.per_app_bindings,
447 dpi_presets: raw.dpi_presets,
448 dpi: raw.dpi,
449 lighting: raw.lighting,
450 smartshift: raw.smartshift,
451 }
452 }
453}
454
455#[derive(Debug, Error)]
456pub enum ConfigError {
457 #[error("could not resolve config path")]
458 Path(#[from] PathsError),
459 #[error("could not read config at {path}")]
460 Read {
461 path: PathBuf,
462 #[source]
463 source: io::Error,
464 },
465 #[error("could not parse config at {path}")]
466 Parse {
467 path: PathBuf,
468 #[source]
469 source: toml::de::Error,
470 },
471 #[error("could not write config at {path}")]
472 Write {
473 path: PathBuf,
474 #[source]
475 source: io::Error,
476 },
477 #[error("could not serialize config")]
478 Serialize(#[from] toml::ser::Error),
479 #[error("config at {path} has unsupported schema_version {found}")]
480 UnsupportedSchemaVersion { path: PathBuf, found: u32 },
481}
482
483#[allow(
484 clippy::result_large_err,
485 reason = "Config I/O keeps rich parse/write context and is not a hot path"
486)]
487impl Config {
488 pub fn load_or_default() -> Result<Self, ConfigError> {
491 Self::load_from_path(&paths::config_path()?)
492 }
493
494 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
497 match fs::read_to_string(path) {
498 Ok(text) => {
499 let mut config: Self =
500 toml::from_str(&text).map_err(|source| ConfigError::Parse {
501 path: path.to_path_buf(),
502 source,
503 })?;
504 if config.schema_version > SCHEMA_VERSION {
510 return Err(ConfigError::UnsupportedSchemaVersion {
511 path: path.to_path_buf(),
512 found: config.schema_version,
513 });
514 }
515 config.schema_version = SCHEMA_VERSION;
519 Ok(config)
520 }
521 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
522 Err(source) => Err(ConfigError::Read {
523 path: path.to_path_buf(),
524 source,
525 }),
526 }
527 }
528
529 pub fn save_atomic(&self) -> Result<(), ConfigError> {
533 self.save_to_path(&paths::config_path()?)
534 }
535
536 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
538 if let Some(parent) = path.parent() {
539 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
540 path: path.to_path_buf(),
541 source,
542 })?;
543 }
544 let body = toml::to_string_pretty(self)?;
545 write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
546 path: path.to_path_buf(),
547 source,
548 })
549 }
550
551 #[must_use]
554 pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
555 self.devices
556 .get(device_key)
557 .map(|d| d.bindings.clone())
558 .unwrap_or_default()
559 }
560
561 pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
566 self.devices
567 .entry(device_key.to_string())
568 .or_default()
569 .bindings
570 .insert(button, binding);
571 }
572
573 #[must_use]
578 pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
579 match self
580 .devices
581 .get(device_key)
582 .and_then(|d| d.bindings.get(&ButtonId::GestureButton))
583 {
584 Some(Binding::Gesture(map)) => map.clone(),
585 _ => BTreeMap::new(),
586 }
587 }
588
589 pub fn set_gesture_direction(
599 &mut self,
600 device_key: &str,
601 button: ButtonId,
602 direction: GestureDirection,
603 action: Action,
604 ) {
605 if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
606 map.insert(direction, action);
607 }
608 }
609
610 fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
618 let entry = self
619 .devices
620 .entry(device_key.to_string())
621 .or_default()
622 .bindings
623 .entry(button)
624 .or_insert_with(|| default_binding_for(button));
625 entry.upgrade_to_gesture();
626 entry
627 }
628
629 #[must_use]
638 pub fn gesture_owner(&self, device_key: &str) -> Option<ButtonId> {
639 let Some(device) = self.devices.get(device_key) else {
640 return Some(ButtonId::GestureButton);
642 };
643 match device.gesture_owner {
644 Some(GestureOwner::Off) => None,
645 Some(GestureOwner::Button(id)) => Some(id),
646 None => Self::infer_gesture_owner(&device.bindings),
647 }
648 }
649
650 fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
655 if let Some((id, _)) = bindings
657 .iter()
658 .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
659 {
660 return Some(*id);
661 }
662 if matches!(
664 bindings.get(&ButtonId::GestureButton),
665 Some(Binding::Single(_))
666 ) {
667 return None;
668 }
669 Some(ButtonId::GestureButton)
671 }
672
673 pub fn set_gesture_owner(&mut self, device_key: &str, button: ButtonId) {
686 self.devices
687 .entry(device_key.to_string())
688 .or_default()
689 .gesture_owner = Some(GestureOwner::Button(button));
690 self.ensure_gesture_binding(device_key, button)
691 .fill_gesture_defaults();
692 }
693
694 pub fn disable_gestures(&mut self, device_key: &str) {
698 self.devices
699 .entry(device_key.to_string())
700 .or_default()
701 .gesture_owner = Some(GestureOwner::Off);
702 }
703
704 #[must_use]
712 pub fn effective_bindings(
713 &self,
714 device_key: &str,
715 bundle_id: Option<&str>,
716 ) -> BTreeMap<ButtonId, Binding> {
717 let Some(device) = self.devices.get(device_key) else {
718 return BTreeMap::new();
719 };
720 let mut out = device.bindings.clone();
721 if let Some(bid) = bundle_id
722 && let Some(overlay) = device.per_app_bindings.get(bid)
723 {
724 for (k, v) in overlay {
725 out.insert(*k, Binding::Single(v.clone()));
726 }
727 }
728 out
729 }
730
731 pub fn set_per_app_binding(
735 &mut self,
736 device_key: &str,
737 bundle_id: &str,
738 button: ButtonId,
739 action: Option<Action>,
740 ) {
741 let entry = self
742 .devices
743 .entry(device_key.to_string())
744 .or_default()
745 .per_app_bindings
746 .entry(bundle_id.to_string())
747 .or_default();
748 match action {
749 Some(a) => {
750 entry.insert(button, a);
751 }
752 None => {
753 entry.remove(&button);
754 }
755 }
756 if let Some(d) = self.devices.get_mut(device_key) {
757 d.per_app_bindings.retain(|_, m| !m.is_empty());
758 }
759 }
760
761 #[must_use]
763 pub fn selected_device(&self) -> Option<&str> {
764 self.selected_device.as_deref()
765 }
766
767 pub fn set_selected_device(&mut self, key: Option<String>) {
770 self.selected_device = key;
771 }
772
773 #[must_use]
776 pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
777 self.devices
778 .get(device_key)
779 .map(|d| d.dpi_presets.clone())
780 .unwrap_or_default()
781 }
782
783 pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
787 self.devices
788 .entry(device_key.to_string())
789 .or_default()
790 .dpi_presets = presets;
791 }
792
793 #[must_use]
797 pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
798 self.devices
799 .get(device_key)
800 .and_then(|d| d.identity.as_ref())
801 }
802
803 pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
806 self.devices
807 .entry(device_key.to_string())
808 .or_default()
809 .identity = Some(identity);
810 }
811
812 pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
816 self.devices
817 .iter()
818 .filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
819 }
820
821 #[must_use]
823 pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
824 self.devices
825 .get(device_key)
826 .and_then(|d| d.lighting.clone())
827 }
828
829 pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
831 self.devices
832 .entry(device_key.to_string())
833 .or_default()
834 .lighting = Some(lighting);
835 }
836
837 #[must_use]
839 pub fn dpi(&self, device_key: &str) -> Option<u32> {
840 self.devices.get(device_key).and_then(|d| d.dpi)
841 }
842
843 pub fn set_dpi(&mut self, device_key: &str, dpi: u32) {
846 self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
847 }
848
849 #[must_use]
851 pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
852 self.devices.get(device_key).and_then(|d| d.smartshift)
853 }
854
855 pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
858 self.devices
859 .entry(device_key.to_string())
860 .or_default()
861 .smartshift = Some(smartshift);
862 }
863}
864
865fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
866 let tmp = path.with_extension("toml.tmp");
867 {
868 #[cfg(unix)]
869 {
870 use std::os::unix::fs::OpenOptionsExt;
871 let mut f = fs::OpenOptions::new()
872 .write(true)
873 .create(true)
874 .truncate(true)
875 .mode(0o600)
876 .open(&tmp)?;
877 io::Write::write_all(&mut f, bytes)?;
878 f.sync_all()?;
879 }
880 #[cfg(not(unix))]
881 {
882 let mut f = fs::OpenOptions::new()
883 .write(true)
884 .create(true)
885 .truncate(true)
886 .open(&tmp)?;
887 io::Write::write_all(&mut f, bytes)?;
888 f.sync_all()?;
889 }
890 }
891 fs::rename(&tmp, path)
892}
893
894#[cfg(test)]
895#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
896mod tests {
897 use super::*;
898 use crate::binding::{default_binding, default_gesture_binding};
899
900 fn write_and_read(config: &Config) -> Config {
901 let dir = tempfile::tempdir().expect("tempdir");
902 let path = dir.path().join("config.toml");
903 config.save_to_path(&path).expect("save");
904 Config::load_from_path(&path).expect("load")
905 }
906
907 #[test]
908 fn missing_file_yields_default() {
909 let dir = tempfile::tempdir().expect("tempdir");
910 let path = dir.path().join("nonexistent.toml");
911 let cfg = Config::load_from_path(&path).expect("load");
912 assert_eq!(cfg.schema_version, SCHEMA_VERSION);
913 assert!(cfg.devices.is_empty());
914 }
915
916 #[test]
917 fn lighting_roundtrips_per_device() {
918 let mut cfg = Config::default();
919 cfg.set_lighting(
920 "g513",
921 Lighting {
922 enabled: true,
923 color: "00aabb".to_string(),
924 brightness: 75,
925 },
926 );
927 let restored = write_and_read(&cfg);
928 assert_eq!(
929 restored.lighting("g513"),
930 Some(Lighting {
931 enabled: true,
932 color: "00aabb".to_string(),
933 brightness: 75,
934 })
935 );
936 assert_eq!(restored.lighting("absent"), None);
937 }
938
939 #[test]
940 fn dpi_roundtrips_per_device() {
941 let mut cfg = Config::default();
942 cfg.set_dpi("2b042", 1600);
943 let restored = write_and_read(&cfg);
944 assert_eq!(restored.dpi("2b042"), Some(1600));
945 assert_eq!(restored.dpi("absent"), None);
946 }
947
948 #[test]
949 fn smartshift_roundtrips_per_device() {
950 let mut cfg = Config::default();
951 cfg.set_smartshift(
952 "2b042",
953 SmartShift {
954 mode: WheelMode::Ratchet,
955 auto_disengage: 16,
956 tunable_torque: 30,
957 },
958 );
959 let restored = write_and_read(&cfg);
960 assert_eq!(
961 restored.smartshift("2b042"),
962 Some(SmartShift {
963 mode: WheelMode::Ratchet,
964 auto_disengage: 16,
965 tunable_torque: 30,
966 })
967 );
968 assert_eq!(restored.smartshift("absent"), None);
969 }
970
971 #[test]
972 fn bindings_roundtrip_per_device() {
973 let mut cfg = Config::default();
974 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
975 cfg.set_binding(
976 "2b042",
977 ButtonId::DpiToggle,
978 Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
979 modifiers: crate::binding::KeyCombo::MOD_CMD,
980 key_code: 0x23, display: "⌘P".into(),
982 })),
983 );
984 cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
985
986 let parsed = write_and_read(&cfg);
987
988 let a = parsed.bindings_for("2b042");
990 assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
991 assert_eq!(
992 a.get(&ButtonId::DpiToggle),
993 Some(&Binding::Single(Action::CustomShortcut(
994 crate::binding::KeyCombo {
995 modifiers: crate::binding::KeyCombo::MOD_CMD,
996 key_code: 0x23,
997 display: "⌘P".into(),
998 }
999 )))
1000 );
1001
1002 let b = parsed.bindings_for("4082d");
1003 assert_eq!(
1004 b.get(&ButtonId::Back),
1005 Some(&Binding::Single(Action::Paste))
1006 );
1007 assert_eq!(b.len(), 1, "device b should only see its own bindings");
1008
1009 assert!(parsed.bindings_for("deadbeef").is_empty());
1011 }
1012
1013 #[test]
1014 fn human_readable_toml_layout() {
1015 let mut cfg = Config::default();
1016 cfg.set_binding(
1017 "2b042",
1018 ButtonId::Back,
1019 Binding::Single(Action::BrowserBack),
1020 );
1021 let body = toml::to_string_pretty(&cfg).expect("serialize");
1022
1023 assert!(body.contains("schema_version = 2"), "got: {body}");
1027 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1028 assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
1031 }
1032
1033 #[test]
1034 fn dpi_presets_roundtrip_per_device() {
1035 let mut cfg = Config::default();
1036 cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
1037 cfg.set_dpi_presets("4082d", vec![400, 1600]);
1038
1039 let parsed = write_and_read(&cfg);
1040
1041 assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
1042 assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
1043 assert!(parsed.dpi_presets("unknown").is_empty());
1044 }
1045
1046 #[test]
1047 fn empty_dpi_presets_skip_serialization() {
1048 let mut cfg = Config::default();
1049 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1051 cfg.set_dpi_presets("2b042", vec![800]);
1052 cfg.set_dpi_presets("2b042", vec![]); let body = toml::to_string_pretty(&cfg).expect("serialize");
1055 assert!(
1056 !body.contains("dpi_presets"),
1057 "empty dpi_presets should be omitted: {body}"
1058 );
1059 }
1060
1061 #[test]
1062 fn device_identity_roundtrips_and_is_iterable() {
1063 use crate::device::{Capabilities, DeviceKind};
1064
1065 let mut cfg = Config::default();
1066 let mouse = DeviceIdentity {
1067 display_name: "MX Master 3S".to_string(),
1068 kind: DeviceKind::Mouse,
1069 capabilities: Capabilities {
1070 buttons: true,
1071 pointer: true,
1072 lighting: false,
1073 },
1074 };
1075 cfg.set_device_identity("2b034", mouse.clone());
1076 cfg.set_binding(
1078 "2b034",
1079 ButtonId::Back,
1080 Binding::Single(Action::BrowserBack),
1081 );
1082
1083 let parsed = write_and_read(&cfg);
1084 assert_eq!(parsed.device_identity("2b034"), Some(&mouse));
1085 assert_eq!(parsed.device_identity("absent"), None);
1086 assert_eq!(
1087 parsed.bindings_for("2b034").get(&ButtonId::Back),
1088 Some(&Binding::Single(Action::BrowserBack)),
1089 "identity must coexist with bindings on the same device block"
1090 );
1091 assert_eq!(
1092 parsed.known_identities().collect::<Vec<_>>(),
1093 vec![("2b034", &mouse)]
1094 );
1095 }
1096
1097 #[test]
1098 fn selected_device_roundtrips() {
1099 let mut cfg = Config::default();
1100 assert_eq!(cfg.selected_device(), None);
1101 cfg.set_selected_device(Some("2b042".into()));
1102 let parsed = write_and_read(&cfg);
1103 assert_eq!(parsed.selected_device(), Some("2b042"));
1104 }
1105
1106 #[test]
1107 fn per_app_overlay_takes_precedence() {
1108 let mut cfg = Config::default();
1109 cfg.set_binding(
1110 "2b042",
1111 ButtonId::Back,
1112 Binding::Single(Action::BrowserBack),
1113 );
1114 cfg.set_binding(
1115 "2b042",
1116 ButtonId::Forward,
1117 Binding::Single(Action::BrowserForward),
1118 );
1119 cfg.set_per_app_binding(
1120 "2b042",
1121 "com.microsoft.VSCode",
1122 ButtonId::Back,
1123 Some(Action::Undo),
1124 );
1125
1126 let global = cfg.effective_bindings("2b042", None);
1128 assert_eq!(
1129 global.get(&ButtonId::Back),
1130 Some(&Binding::Single(Action::BrowserBack))
1131 );
1132 assert_eq!(
1133 global.get(&ButtonId::Forward),
1134 Some(&Binding::Single(Action::BrowserForward))
1135 );
1136
1137 let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
1139 assert_eq!(
1140 vscode.get(&ButtonId::Back),
1141 Some(&Binding::Single(Action::Undo))
1142 );
1143 assert_eq!(
1144 vscode.get(&ButtonId::Forward),
1145 Some(&Binding::Single(Action::BrowserForward))
1146 );
1147
1148 let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
1150 assert_eq!(
1151 other.get(&ButtonId::Back),
1152 Some(&Binding::Single(Action::BrowserBack))
1153 );
1154 }
1155
1156 #[test]
1157 fn per_app_binding_removal_prunes_empty_app() {
1158 let mut cfg = Config::default();
1159 cfg.set_per_app_binding(
1160 "2b042",
1161 "com.example.App",
1162 ButtonId::Back,
1163 Some(Action::Copy),
1164 );
1165 cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
1166 assert!(
1167 cfg.devices["2b042"].per_app_bindings.is_empty(),
1168 "removing last override should prune the app entry"
1169 );
1170 }
1171
1172 #[test]
1173 fn app_settings_default_omits_block() {
1174 let cfg = Config::default();
1175 let body = toml::to_string_pretty(&cfg).expect("serialize");
1176 assert!(
1177 !body.contains("app_settings"),
1178 "default app_settings should be omitted: {body}"
1179 );
1180 }
1181
1182 #[test]
1183 fn app_settings_launch_at_login_roundtrips() {
1184 let mut cfg = Config::default();
1185 cfg.app_settings.launch_at_login = true;
1186 let parsed = write_and_read(&cfg);
1187 assert!(parsed.app_settings.launch_at_login);
1188 }
1189
1190 #[test]
1191 fn cleared_selected_device_omits_field() {
1192 let mut cfg = Config::default();
1193 cfg.set_selected_device(Some("2b042".into()));
1194 cfg.set_selected_device(None);
1195 let body = toml::to_string_pretty(&cfg).expect("serialize");
1196 assert!(
1197 !body.contains("selected_device"),
1198 "cleared selection should not appear: {body}"
1199 );
1200 }
1201
1202 #[test]
1203 fn empty_device_block_is_skipped_in_output() {
1204 let mut cfg = Config::default();
1207 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1208 cfg.devices
1209 .get_mut("2b042")
1210 .expect("entry")
1211 .bindings
1212 .clear();
1213 let body = toml::to_string_pretty(&cfg).expect("serialize");
1214 assert!(
1215 !body.contains("Back"),
1216 "cleared bindings should not appear: {body}"
1217 );
1218 }
1219
1220 #[test]
1221 fn migrates_v1_button_and_gesture_bindings() {
1222 let v1 = "\
1224schema_version = 1
1225
1226[devices.2b042.button_bindings]
1227Back = \"BrowserBack\"
1228
1229[devices.2b042.gesture_bindings]
1230Up = \"Copy\"
1231Click = \"Paste\"
1232";
1233 let dir = tempfile::tempdir().expect("tempdir");
1234 let path = dir.path().join("config.toml");
1235 fs::write(&path, v1).expect("write");
1236
1237 let cfg = Config::load_from_path(&path).expect("load v1");
1239 let bindings = cfg.bindings_for("2b042");
1240 assert_eq!(
1241 bindings.get(&ButtonId::Back),
1242 Some(&Binding::Single(Action::BrowserBack))
1243 );
1244 let mut gesture = BTreeMap::new();
1245 gesture.insert(GestureDirection::Up, Action::Copy);
1246 gesture.insert(GestureDirection::Click, Action::Paste);
1247 assert_eq!(
1248 bindings.get(&ButtonId::GestureButton),
1249 Some(&Binding::Gesture(gesture))
1250 );
1251
1252 let body = toml::to_string_pretty(&cfg).expect("serialize");
1255 assert!(body.contains("schema_version = 2"), "got: {body}");
1256 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1257 assert!(!body.contains("button_bindings"), "got: {body}");
1258 assert!(!body.contains("gesture_bindings"), "got: {body}");
1259 }
1260
1261 #[test]
1262 fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
1263 let v1 = "\
1268schema_version = 1
1269
1270[devices.2b042.button_bindings]
1271GestureButton = \"MissionControl\"
1272
1273[devices.2b042.gesture_bindings]
1274Up = \"Copy\"
1275Down = \"Paste\"
1276";
1277 let dir = tempfile::tempdir().expect("tempdir");
1278 let path = dir.path().join("config.toml");
1279 fs::write(&path, v1).expect("write");
1280
1281 let cfg = Config::load_from_path(&path).expect("load v1");
1282 let mut gesture = BTreeMap::new();
1283 gesture.insert(GestureDirection::Up, Action::Copy);
1284 gesture.insert(GestureDirection::Down, Action::Paste);
1285 assert_eq!(
1286 cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
1287 Some(&Binding::Gesture(gesture)),
1288 "gesture map must win over the legacy single GestureButton entry"
1289 );
1290 }
1291
1292 #[test]
1293 fn migration_drops_vestigial_lone_gesture_button_single() {
1294 let v1 = "\
1301schema_version = 1
1302
1303[devices.2b042.button_bindings]
1304GestureButton = \"MissionControl\"
1305Back = \"BrowserBack\"
1306";
1307 let dir = tempfile::tempdir().expect("tempdir");
1308 let path = dir.path().join("config.toml");
1309 fs::write(&path, v1).expect("write");
1310
1311 let bindings = Config::load_from_path(&path)
1312 .expect("load v1")
1313 .bindings_for("2b042");
1314 assert_eq!(
1316 bindings.get(&ButtonId::Back),
1317 Some(&Binding::Single(Action::BrowserBack))
1318 );
1319 assert_eq!(bindings.get(&ButtonId::GestureButton), None);
1322 }
1323
1324 #[test]
1325 fn rejects_newer_schema_version_but_accepts_v1() {
1326 let dir = tempfile::tempdir().expect("tempdir");
1329 let path = dir.path().join("config.toml");
1330 fs::write(&path, "schema_version = 99\n").expect("write");
1331 assert!(matches!(
1332 Config::load_from_path(&path).expect_err("v99 should fail"),
1333 ConfigError::UnsupportedSchemaVersion { found: 99, .. }
1334 ));
1335
1336 fs::write(&path, "schema_version = 1\n").expect("write");
1337 assert!(
1338 Config::load_from_path(&path).is_ok(),
1339 "v1 should still load"
1340 );
1341 }
1342
1343 #[test]
1344 fn set_gesture_direction_upgrades_single_to_gesture() {
1345 let mut cfg = Config::default();
1346 cfg.set_binding(
1348 "2b042",
1349 ButtonId::Back,
1350 Binding::Single(Action::BrowserBack),
1351 );
1352 cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
1353
1354 match cfg.bindings_for("2b042").get(&ButtonId::Back) {
1355 Some(Binding::Gesture(map)) => {
1356 assert_eq!(
1358 map.get(&GestureDirection::Click),
1359 Some(&Action::BrowserBack)
1360 );
1361 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1362 }
1363 other => panic!("expected Gesture after upgrade, got {other:?}"),
1364 }
1365 }
1366
1367 #[test]
1368 fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
1369 let mut cfg = Config::default();
1373 cfg.set_gesture_direction(
1374 "2b042",
1375 ButtonId::GestureButton,
1376 GestureDirection::Up,
1377 Action::Copy,
1378 );
1379
1380 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1381 Some(Binding::Gesture(map)) => {
1382 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1383 assert_eq!(
1384 map.get(&GestureDirection::Click),
1385 Some(&crate::binding::default_gesture_binding(
1386 GestureDirection::Click
1387 )),
1388 "a fresh gesture button must seed a Click from its default"
1389 );
1390 }
1391 other => panic!("expected Gesture, got {other:?}"),
1392 }
1393 }
1394
1395 #[test]
1396 fn gesture_owner_defaults_to_thumb_pad_yields_to_oshook_and_can_be_off() {
1397 let mut cfg = Config::default();
1398 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1400
1401 cfg.set_gesture_direction(
1403 "2b042",
1404 ButtonId::GestureButton,
1405 GestureDirection::Up,
1406 Action::MissionControl,
1407 );
1408 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1409
1410 cfg.set_binding(
1412 "2b042",
1413 ButtonId::Forward,
1414 Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
1415 );
1416 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
1417
1418 let mut off = Config::default();
1420 off.disable_gestures("2b042");
1421 assert_eq!(off.gesture_owner("2b042"), None);
1422 }
1423
1424 #[test]
1425 fn set_gesture_owner_records_owner_without_destroying_other_maps() {
1426 let mut cfg = Config::default();
1427 cfg.set_gesture_direction(
1429 "2b042",
1430 ButtonId::GestureButton,
1431 GestureDirection::Up,
1432 Action::Copy,
1433 );
1434 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1435
1436 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
1439 cfg.set_gesture_owner("2b042", ButtonId::Back);
1440 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
1441
1442 let bindings = cfg.bindings_for("2b042");
1443 match bindings.get(&ButtonId::Back) {
1446 Some(Binding::Gesture(map)) => {
1447 assert_eq!(
1448 map.get(&GestureDirection::Click),
1449 Some(&Action::BrowserBack)
1450 );
1451 assert_eq!(
1452 map.get(&GestureDirection::Up),
1453 Some(&default_gesture_binding(GestureDirection::Up)),
1454 "a promoted button gets full default arms"
1455 );
1456 }
1457 other => panic!("expected Back to be a gesture binding, got {other:?}"),
1458 }
1459 match bindings.get(&ButtonId::GestureButton) {
1461 Some(Binding::Gesture(map)) => {
1462 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1463 }
1464 other => panic!("expected the thumb pad map preserved, got {other:?}"),
1465 }
1466
1467 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1470 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1471 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1472 Some(Binding::Gesture(map)) => {
1473 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1474 }
1475 other => panic!("expected preserved gesture map, got {other:?}"),
1476 }
1477 }
1478
1479 #[test]
1480 fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
1481 let mut cfg = Config::default();
1482 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1484 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1485 Some(Binding::Gesture(map)) => {
1486 for dir in GestureDirection::ALL {
1487 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1488 }
1489 }
1490 other => panic!("expected full default gesture map, got {other:?}"),
1491 }
1492
1493 cfg.set_gesture_owner("2b042", ButtonId::Forward);
1497 match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
1498 Some(Binding::Gesture(map)) => {
1499 assert_eq!(
1500 map.get(&GestureDirection::Click),
1501 Some(&default_binding(ButtonId::Forward))
1502 );
1503 for dir in [
1504 GestureDirection::Up,
1505 GestureDirection::Down,
1506 GestureDirection::Left,
1507 GestureDirection::Right,
1508 ] {
1509 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1510 }
1511 }
1512 other => panic!("expected full gesture map for Forward, got {other:?}"),
1513 }
1514 }
1515
1516 #[test]
1517 fn disable_gestures_turns_off_without_destroying_maps() {
1518 let mut cfg = Config::default();
1519 cfg.set_gesture_direction(
1520 "2b042",
1521 ButtonId::GestureButton,
1522 GestureDirection::Up,
1523 Action::Copy,
1524 );
1525 cfg.disable_gestures("2b042");
1526 assert_eq!(cfg.gesture_owner("2b042"), None);
1529 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1530 Some(Binding::Gesture(map)) => {
1531 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1532 }
1533 other => panic!("expected the gesture map preserved while off, got {other:?}"),
1534 }
1535 }
1536
1537 #[test]
1538 fn gesture_owner_field_roundtrips_as_a_scalar() {
1539 let mut cfg = Config::default();
1540 cfg.set_gesture_owner("2b042", ButtonId::Back); cfg.disable_gestures("4082d"); let parsed = write_and_read(&cfg);
1544 assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
1545 assert_eq!(parsed.gesture_owner("4082d"), None);
1546
1547 let body = toml::to_string_pretty(&cfg).expect("serialize");
1550 assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
1551 assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
1552 }
1553
1554 #[test]
1555 fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
1556 let toml = "\
1560schema_version = 2
1561
1562[devices.2b042]
1563gesture_owner = \"bogus\"
1564
1565[devices.2b042.bindings]
1566Back = \"Copy\"
1567";
1568 let dir = tempfile::tempdir().expect("tempdir");
1569 let path = dir.path().join("config.toml");
1570 fs::write(&path, toml).expect("write");
1571
1572 let cfg =
1573 Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
1574 assert_eq!(
1576 cfg.bindings_for("2b042").get(&ButtonId::Back),
1577 Some(&Binding::Single(Action::Copy))
1578 );
1579 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1581 }
1582}