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) {
685 self.devices
686 .entry(device_key.to_string())
687 .or_default()
688 .gesture_owner = Some(GestureOwner::Button(button));
689 self.ensure_gesture_binding(device_key, button)
690 .fill_gesture_defaults();
691 }
692
693 pub fn disable_gestures(&mut self, device_key: &str) {
697 self.devices
698 .entry(device_key.to_string())
699 .or_default()
700 .gesture_owner = Some(GestureOwner::Off);
701 }
702
703 #[must_use]
711 pub fn effective_bindings(
712 &self,
713 device_key: &str,
714 bundle_id: Option<&str>,
715 ) -> BTreeMap<ButtonId, Binding> {
716 let Some(device) = self.devices.get(device_key) else {
717 return BTreeMap::new();
718 };
719 let mut out = device.bindings.clone();
720 if let Some(bid) = bundle_id
721 && let Some(overlay) = device.per_app_bindings.get(bid)
722 {
723 for (k, v) in overlay {
724 out.insert(*k, Binding::Single(v.clone()));
725 }
726 }
727 out
728 }
729
730 pub fn set_per_app_binding(
734 &mut self,
735 device_key: &str,
736 bundle_id: &str,
737 button: ButtonId,
738 action: Option<Action>,
739 ) {
740 let entry = self
741 .devices
742 .entry(device_key.to_string())
743 .or_default()
744 .per_app_bindings
745 .entry(bundle_id.to_string())
746 .or_default();
747 match action {
748 Some(a) => {
749 entry.insert(button, a);
750 }
751 None => {
752 entry.remove(&button);
753 }
754 }
755 if let Some(d) = self.devices.get_mut(device_key) {
756 d.per_app_bindings.retain(|_, m| !m.is_empty());
757 }
758 }
759
760 #[must_use]
762 pub fn selected_device(&self) -> Option<&str> {
763 self.selected_device.as_deref()
764 }
765
766 pub fn set_selected_device(&mut self, key: Option<String>) {
769 self.selected_device = key;
770 }
771
772 #[must_use]
775 pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
776 self.devices
777 .get(device_key)
778 .map(|d| d.dpi_presets.clone())
779 .unwrap_or_default()
780 }
781
782 pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
786 self.devices
787 .entry(device_key.to_string())
788 .or_default()
789 .dpi_presets = presets;
790 }
791
792 #[must_use]
796 pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
797 self.devices
798 .get(device_key)
799 .and_then(|d| d.identity.as_ref())
800 }
801
802 pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
805 self.devices
806 .entry(device_key.to_string())
807 .or_default()
808 .identity = Some(identity);
809 }
810
811 pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
815 self.devices
816 .iter()
817 .filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
818 }
819
820 #[must_use]
822 pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
823 self.devices
824 .get(device_key)
825 .and_then(|d| d.lighting.clone())
826 }
827
828 pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
830 self.devices
831 .entry(device_key.to_string())
832 .or_default()
833 .lighting = Some(lighting);
834 }
835
836 #[must_use]
838 pub fn dpi(&self, device_key: &str) -> Option<u32> {
839 self.devices.get(device_key).and_then(|d| d.dpi)
840 }
841
842 pub fn set_dpi(&mut self, device_key: &str, dpi: u32) {
845 self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
846 }
847
848 #[must_use]
850 pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
851 self.devices.get(device_key).and_then(|d| d.smartshift)
852 }
853
854 pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
857 self.devices
858 .entry(device_key.to_string())
859 .or_default()
860 .smartshift = Some(smartshift);
861 }
862}
863
864fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
865 let tmp = path.with_extension("toml.tmp");
866 {
867 #[cfg(unix)]
868 {
869 use std::os::unix::fs::OpenOptionsExt;
870 let mut f = fs::OpenOptions::new()
871 .write(true)
872 .create(true)
873 .truncate(true)
874 .mode(0o600)
875 .open(&tmp)?;
876 io::Write::write_all(&mut f, bytes)?;
877 f.sync_all()?;
878 }
879 #[cfg(not(unix))]
880 {
881 let mut f = fs::OpenOptions::new()
882 .write(true)
883 .create(true)
884 .truncate(true)
885 .open(&tmp)?;
886 io::Write::write_all(&mut f, bytes)?;
887 f.sync_all()?;
888 }
889 }
890 fs::rename(&tmp, path)
891}
892
893#[cfg(test)]
894#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
895mod tests {
896 use super::*;
897 use crate::binding::{default_binding, default_gesture_binding};
898
899 fn write_and_read(config: &Config) -> Config {
900 let dir = tempfile::tempdir().expect("tempdir");
901 let path = dir.path().join("config.toml");
902 config.save_to_path(&path).expect("save");
903 Config::load_from_path(&path).expect("load")
904 }
905
906 #[test]
907 fn missing_file_yields_default() {
908 let dir = tempfile::tempdir().expect("tempdir");
909 let path = dir.path().join("nonexistent.toml");
910 let cfg = Config::load_from_path(&path).expect("load");
911 assert_eq!(cfg.schema_version, SCHEMA_VERSION);
912 assert!(cfg.devices.is_empty());
913 }
914
915 #[test]
916 fn lighting_roundtrips_per_device() {
917 let mut cfg = Config::default();
918 cfg.set_lighting(
919 "g513",
920 Lighting {
921 enabled: true,
922 color: "00aabb".to_string(),
923 brightness: 75,
924 },
925 );
926 let restored = write_and_read(&cfg);
927 assert_eq!(
928 restored.lighting("g513"),
929 Some(Lighting {
930 enabled: true,
931 color: "00aabb".to_string(),
932 brightness: 75,
933 })
934 );
935 assert_eq!(restored.lighting("absent"), None);
936 }
937
938 #[test]
939 fn dpi_roundtrips_per_device() {
940 let mut cfg = Config::default();
941 cfg.set_dpi("2b042", 1600);
942 let restored = write_and_read(&cfg);
943 assert_eq!(restored.dpi("2b042"), Some(1600));
944 assert_eq!(restored.dpi("absent"), None);
945 }
946
947 #[test]
948 fn smartshift_roundtrips_per_device() {
949 let mut cfg = Config::default();
950 cfg.set_smartshift(
951 "2b042",
952 SmartShift {
953 mode: WheelMode::Ratchet,
954 auto_disengage: 16,
955 tunable_torque: 30,
956 },
957 );
958 let restored = write_and_read(&cfg);
959 assert_eq!(
960 restored.smartshift("2b042"),
961 Some(SmartShift {
962 mode: WheelMode::Ratchet,
963 auto_disengage: 16,
964 tunable_torque: 30,
965 })
966 );
967 assert_eq!(restored.smartshift("absent"), None);
968 }
969
970 #[test]
971 fn bindings_roundtrip_per_device() {
972 let mut cfg = Config::default();
973 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
974 cfg.set_binding(
975 "2b042",
976 ButtonId::DpiToggle,
977 Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
978 modifiers: crate::binding::KeyCombo::MOD_CMD,
979 key_code: 0x23, display: "⌘P".into(),
981 })),
982 );
983 cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
984
985 let parsed = write_and_read(&cfg);
986
987 let a = parsed.bindings_for("2b042");
989 assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
990 assert_eq!(
991 a.get(&ButtonId::DpiToggle),
992 Some(&Binding::Single(Action::CustomShortcut(
993 crate::binding::KeyCombo {
994 modifiers: crate::binding::KeyCombo::MOD_CMD,
995 key_code: 0x23,
996 display: "⌘P".into(),
997 }
998 )))
999 );
1000
1001 let b = parsed.bindings_for("4082d");
1002 assert_eq!(
1003 b.get(&ButtonId::Back),
1004 Some(&Binding::Single(Action::Paste))
1005 );
1006 assert_eq!(b.len(), 1, "device b should only see its own bindings");
1007
1008 assert!(parsed.bindings_for("deadbeef").is_empty());
1010 }
1011
1012 #[test]
1013 fn human_readable_toml_layout() {
1014 let mut cfg = Config::default();
1015 cfg.set_binding(
1016 "2b042",
1017 ButtonId::Back,
1018 Binding::Single(Action::BrowserBack),
1019 );
1020 let body = toml::to_string_pretty(&cfg).expect("serialize");
1021
1022 assert!(body.contains("schema_version = 2"), "got: {body}");
1026 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1027 assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
1030 }
1031
1032 #[test]
1033 fn dpi_presets_roundtrip_per_device() {
1034 let mut cfg = Config::default();
1035 cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
1036 cfg.set_dpi_presets("4082d", vec![400, 1600]);
1037
1038 let parsed = write_and_read(&cfg);
1039
1040 assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
1041 assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
1042 assert!(parsed.dpi_presets("unknown").is_empty());
1043 }
1044
1045 #[test]
1046 fn empty_dpi_presets_skip_serialization() {
1047 let mut cfg = Config::default();
1048 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1050 cfg.set_dpi_presets("2b042", vec![800]);
1051 cfg.set_dpi_presets("2b042", vec![]); let body = toml::to_string_pretty(&cfg).expect("serialize");
1054 assert!(
1055 !body.contains("dpi_presets"),
1056 "empty dpi_presets should be omitted: {body}"
1057 );
1058 }
1059
1060 #[test]
1061 fn device_identity_roundtrips_and_is_iterable() {
1062 use crate::device::{Capabilities, DeviceKind};
1063
1064 let mut cfg = Config::default();
1065 let mouse = DeviceIdentity {
1066 display_name: "MX Master 3S".to_string(),
1067 kind: DeviceKind::Mouse,
1068 capabilities: Capabilities {
1069 buttons: true,
1070 pointer: true,
1071 lighting: false,
1072 },
1073 };
1074 cfg.set_device_identity("2b034", mouse.clone());
1075 cfg.set_binding(
1077 "2b034",
1078 ButtonId::Back,
1079 Binding::Single(Action::BrowserBack),
1080 );
1081
1082 let parsed = write_and_read(&cfg);
1083 assert_eq!(parsed.device_identity("2b034"), Some(&mouse));
1084 assert_eq!(parsed.device_identity("absent"), None);
1085 assert_eq!(
1086 parsed.bindings_for("2b034").get(&ButtonId::Back),
1087 Some(&Binding::Single(Action::BrowserBack)),
1088 "identity must coexist with bindings on the same device block"
1089 );
1090 assert_eq!(
1091 parsed.known_identities().collect::<Vec<_>>(),
1092 vec![("2b034", &mouse)]
1093 );
1094 }
1095
1096 #[test]
1097 fn selected_device_roundtrips() {
1098 let mut cfg = Config::default();
1099 assert_eq!(cfg.selected_device(), None);
1100 cfg.set_selected_device(Some("2b042".into()));
1101 let parsed = write_and_read(&cfg);
1102 assert_eq!(parsed.selected_device(), Some("2b042"));
1103 }
1104
1105 #[test]
1106 fn per_app_overlay_takes_precedence() {
1107 let mut cfg = Config::default();
1108 cfg.set_binding(
1109 "2b042",
1110 ButtonId::Back,
1111 Binding::Single(Action::BrowserBack),
1112 );
1113 cfg.set_binding(
1114 "2b042",
1115 ButtonId::Forward,
1116 Binding::Single(Action::BrowserForward),
1117 );
1118 cfg.set_per_app_binding(
1119 "2b042",
1120 "com.microsoft.VSCode",
1121 ButtonId::Back,
1122 Some(Action::Undo),
1123 );
1124
1125 let global = cfg.effective_bindings("2b042", None);
1127 assert_eq!(
1128 global.get(&ButtonId::Back),
1129 Some(&Binding::Single(Action::BrowserBack))
1130 );
1131 assert_eq!(
1132 global.get(&ButtonId::Forward),
1133 Some(&Binding::Single(Action::BrowserForward))
1134 );
1135
1136 let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
1138 assert_eq!(
1139 vscode.get(&ButtonId::Back),
1140 Some(&Binding::Single(Action::Undo))
1141 );
1142 assert_eq!(
1143 vscode.get(&ButtonId::Forward),
1144 Some(&Binding::Single(Action::BrowserForward))
1145 );
1146
1147 let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
1149 assert_eq!(
1150 other.get(&ButtonId::Back),
1151 Some(&Binding::Single(Action::BrowserBack))
1152 );
1153 }
1154
1155 #[test]
1156 fn per_app_binding_removal_prunes_empty_app() {
1157 let mut cfg = Config::default();
1158 cfg.set_per_app_binding(
1159 "2b042",
1160 "com.example.App",
1161 ButtonId::Back,
1162 Some(Action::Copy),
1163 );
1164 cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
1165 assert!(
1166 cfg.devices["2b042"].per_app_bindings.is_empty(),
1167 "removing last override should prune the app entry"
1168 );
1169 }
1170
1171 #[test]
1172 fn app_settings_default_omits_block() {
1173 let cfg = Config::default();
1174 let body = toml::to_string_pretty(&cfg).expect("serialize");
1175 assert!(
1176 !body.contains("app_settings"),
1177 "default app_settings should be omitted: {body}"
1178 );
1179 }
1180
1181 #[test]
1182 fn app_settings_launch_at_login_roundtrips() {
1183 let mut cfg = Config::default();
1184 cfg.app_settings.launch_at_login = true;
1185 let parsed = write_and_read(&cfg);
1186 assert!(parsed.app_settings.launch_at_login);
1187 }
1188
1189 #[test]
1190 fn cleared_selected_device_omits_field() {
1191 let mut cfg = Config::default();
1192 cfg.set_selected_device(Some("2b042".into()));
1193 cfg.set_selected_device(None);
1194 let body = toml::to_string_pretty(&cfg).expect("serialize");
1195 assert!(
1196 !body.contains("selected_device"),
1197 "cleared selection should not appear: {body}"
1198 );
1199 }
1200
1201 #[test]
1202 fn empty_device_block_is_skipped_in_output() {
1203 let mut cfg = Config::default();
1206 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1207 cfg.devices
1208 .get_mut("2b042")
1209 .expect("entry")
1210 .bindings
1211 .clear();
1212 let body = toml::to_string_pretty(&cfg).expect("serialize");
1213 assert!(
1214 !body.contains("Back"),
1215 "cleared bindings should not appear: {body}"
1216 );
1217 }
1218
1219 #[test]
1220 fn migrates_v1_button_and_gesture_bindings() {
1221 let v1 = "\
1223schema_version = 1
1224
1225[devices.2b042.button_bindings]
1226Back = \"BrowserBack\"
1227
1228[devices.2b042.gesture_bindings]
1229Up = \"Copy\"
1230Click = \"Paste\"
1231";
1232 let dir = tempfile::tempdir().expect("tempdir");
1233 let path = dir.path().join("config.toml");
1234 fs::write(&path, v1).expect("write");
1235
1236 let cfg = Config::load_from_path(&path).expect("load v1");
1238 let bindings = cfg.bindings_for("2b042");
1239 assert_eq!(
1240 bindings.get(&ButtonId::Back),
1241 Some(&Binding::Single(Action::BrowserBack))
1242 );
1243 let mut gesture = BTreeMap::new();
1244 gesture.insert(GestureDirection::Up, Action::Copy);
1245 gesture.insert(GestureDirection::Click, Action::Paste);
1246 assert_eq!(
1247 bindings.get(&ButtonId::GestureButton),
1248 Some(&Binding::Gesture(gesture))
1249 );
1250
1251 let body = toml::to_string_pretty(&cfg).expect("serialize");
1254 assert!(body.contains("schema_version = 2"), "got: {body}");
1255 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1256 assert!(!body.contains("button_bindings"), "got: {body}");
1257 assert!(!body.contains("gesture_bindings"), "got: {body}");
1258 }
1259
1260 #[test]
1261 fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
1262 let v1 = "\
1267schema_version = 1
1268
1269[devices.2b042.button_bindings]
1270GestureButton = \"MissionControl\"
1271
1272[devices.2b042.gesture_bindings]
1273Up = \"Copy\"
1274Down = \"Paste\"
1275";
1276 let dir = tempfile::tempdir().expect("tempdir");
1277 let path = dir.path().join("config.toml");
1278 fs::write(&path, v1).expect("write");
1279
1280 let cfg = Config::load_from_path(&path).expect("load v1");
1281 let mut gesture = BTreeMap::new();
1282 gesture.insert(GestureDirection::Up, Action::Copy);
1283 gesture.insert(GestureDirection::Down, Action::Paste);
1284 assert_eq!(
1285 cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
1286 Some(&Binding::Gesture(gesture)),
1287 "gesture map must win over the legacy single GestureButton entry"
1288 );
1289 }
1290
1291 #[test]
1292 fn migration_drops_vestigial_lone_gesture_button_single() {
1293 let v1 = "\
1300schema_version = 1
1301
1302[devices.2b042.button_bindings]
1303GestureButton = \"MissionControl\"
1304Back = \"BrowserBack\"
1305";
1306 let dir = tempfile::tempdir().expect("tempdir");
1307 let path = dir.path().join("config.toml");
1308 fs::write(&path, v1).expect("write");
1309
1310 let bindings = Config::load_from_path(&path)
1311 .expect("load v1")
1312 .bindings_for("2b042");
1313 assert_eq!(
1315 bindings.get(&ButtonId::Back),
1316 Some(&Binding::Single(Action::BrowserBack))
1317 );
1318 assert_eq!(bindings.get(&ButtonId::GestureButton), None);
1321 }
1322
1323 #[test]
1324 fn rejects_newer_schema_version_but_accepts_v1() {
1325 let dir = tempfile::tempdir().expect("tempdir");
1328 let path = dir.path().join("config.toml");
1329 fs::write(&path, "schema_version = 99\n").expect("write");
1330 assert!(matches!(
1331 Config::load_from_path(&path).expect_err("v99 should fail"),
1332 ConfigError::UnsupportedSchemaVersion { found: 99, .. }
1333 ));
1334
1335 fs::write(&path, "schema_version = 1\n").expect("write");
1336 assert!(
1337 Config::load_from_path(&path).is_ok(),
1338 "v1 should still load"
1339 );
1340 }
1341
1342 #[test]
1343 fn set_gesture_direction_upgrades_single_to_gesture() {
1344 let mut cfg = Config::default();
1345 cfg.set_binding(
1347 "2b042",
1348 ButtonId::Back,
1349 Binding::Single(Action::BrowserBack),
1350 );
1351 cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
1352
1353 match cfg.bindings_for("2b042").get(&ButtonId::Back) {
1354 Some(Binding::Gesture(map)) => {
1355 assert_eq!(
1357 map.get(&GestureDirection::Click),
1358 Some(&Action::BrowserBack)
1359 );
1360 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1361 }
1362 other => panic!("expected Gesture after upgrade, got {other:?}"),
1363 }
1364 }
1365
1366 #[test]
1367 fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
1368 let mut cfg = Config::default();
1372 cfg.set_gesture_direction(
1373 "2b042",
1374 ButtonId::GestureButton,
1375 GestureDirection::Up,
1376 Action::Copy,
1377 );
1378
1379 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1380 Some(Binding::Gesture(map)) => {
1381 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1382 assert_eq!(
1383 map.get(&GestureDirection::Click),
1384 Some(&crate::binding::default_gesture_binding(
1385 GestureDirection::Click
1386 )),
1387 "a fresh gesture button must seed a Click from its default"
1388 );
1389 }
1390 other => panic!("expected Gesture, got {other:?}"),
1391 }
1392 }
1393
1394 #[test]
1395 fn gesture_owner_defaults_to_thumb_pad_yields_to_oshook_and_can_be_off() {
1396 let mut cfg = Config::default();
1397 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1399
1400 cfg.set_gesture_direction(
1402 "2b042",
1403 ButtonId::GestureButton,
1404 GestureDirection::Up,
1405 Action::MissionControl,
1406 );
1407 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1408
1409 cfg.set_binding(
1411 "2b042",
1412 ButtonId::Forward,
1413 Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
1414 );
1415 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
1416
1417 let mut off = Config::default();
1419 off.disable_gestures("2b042");
1420 assert_eq!(off.gesture_owner("2b042"), None);
1421 }
1422
1423 #[test]
1424 fn set_gesture_owner_records_owner_without_destroying_other_maps() {
1425 let mut cfg = Config::default();
1426 cfg.set_gesture_direction(
1428 "2b042",
1429 ButtonId::GestureButton,
1430 GestureDirection::Up,
1431 Action::Copy,
1432 );
1433 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1434
1435 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
1438 cfg.set_gesture_owner("2b042", ButtonId::Back);
1439 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
1440
1441 let bindings = cfg.bindings_for("2b042");
1442 match bindings.get(&ButtonId::Back) {
1445 Some(Binding::Gesture(map)) => {
1446 assert_eq!(
1447 map.get(&GestureDirection::Click),
1448 Some(&Action::BrowserBack)
1449 );
1450 assert_eq!(
1451 map.get(&GestureDirection::Up),
1452 Some(&default_gesture_binding(GestureDirection::Up)),
1453 "a promoted button gets full default arms"
1454 );
1455 }
1456 other => panic!("expected Back to be a gesture binding, got {other:?}"),
1457 }
1458 match bindings.get(&ButtonId::GestureButton) {
1460 Some(Binding::Gesture(map)) => {
1461 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1462 }
1463 other => panic!("expected the thumb pad map preserved, got {other:?}"),
1464 }
1465
1466 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1469 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1470 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1471 Some(Binding::Gesture(map)) => {
1472 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1473 }
1474 other => panic!("expected preserved gesture map, got {other:?}"),
1475 }
1476 }
1477
1478 #[test]
1479 fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
1480 let mut cfg = Config::default();
1481 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1483 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1484 Some(Binding::Gesture(map)) => {
1485 for dir in GestureDirection::ALL {
1486 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1487 }
1488 }
1489 other => panic!("expected full default gesture map, got {other:?}"),
1490 }
1491
1492 cfg.set_gesture_owner("2b042", ButtonId::Forward);
1496 match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
1497 Some(Binding::Gesture(map)) => {
1498 assert_eq!(
1499 map.get(&GestureDirection::Click),
1500 Some(&default_binding(ButtonId::Forward))
1501 );
1502 for dir in [
1503 GestureDirection::Up,
1504 GestureDirection::Down,
1505 GestureDirection::Left,
1506 GestureDirection::Right,
1507 ] {
1508 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1509 }
1510 }
1511 other => panic!("expected full gesture map for Forward, got {other:?}"),
1512 }
1513 }
1514
1515 #[test]
1516 fn disable_gestures_turns_off_without_destroying_maps() {
1517 let mut cfg = Config::default();
1518 cfg.set_gesture_direction(
1519 "2b042",
1520 ButtonId::GestureButton,
1521 GestureDirection::Up,
1522 Action::Copy,
1523 );
1524 cfg.disable_gestures("2b042");
1525 assert_eq!(cfg.gesture_owner("2b042"), None);
1528 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1529 Some(Binding::Gesture(map)) => {
1530 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1531 }
1532 other => panic!("expected the gesture map preserved while off, got {other:?}"),
1533 }
1534 }
1535
1536 #[test]
1537 fn gesture_owner_field_roundtrips_as_a_scalar() {
1538 let mut cfg = Config::default();
1539 cfg.set_gesture_owner("2b042", ButtonId::Back); cfg.disable_gestures("4082d"); let parsed = write_and_read(&cfg);
1543 assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
1544 assert_eq!(parsed.gesture_owner("4082d"), None);
1545
1546 let body = toml::to_string_pretty(&cfg).expect("serialize");
1549 assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
1550 assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
1551 }
1552
1553 #[test]
1554 fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
1555 let toml = "\
1559schema_version = 2
1560
1561[devices.2b042]
1562gesture_owner = \"bogus\"
1563
1564[devices.2b042.bindings]
1565Back = \"Copy\"
1566";
1567 let dir = tempfile::tempdir().expect("tempdir");
1568 let path = dir.path().join("config.toml");
1569 fs::write(&path, toml).expect("write");
1570
1571 let cfg =
1572 Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
1573 assert_eq!(
1575 cfg.bindings_for("2b042").get(&ButtonId::Back),
1576 Some(&Binding::Single(Action::Copy))
1577 );
1578 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1580 }
1581}