1use std::{
10 collections::BTreeMap,
11 fs, io,
12 path::{Path, PathBuf},
13};
14
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17
18use crate::binding::{Action, Binding, ButtonId, GestureDirection, default_binding_for};
19use crate::device::{Capabilities, DeviceKind, DeviceModelInfo};
20use crate::paths::{self, PathsError};
21
22pub const SCHEMA_VERSION: u32 = 3;
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Config {
40 pub schema_version: u32,
41 #[serde(default, skip_serializing_if = "AppSettings::is_default")]
43 pub app_settings: AppSettings,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub selected_device: Option<String>,
49 #[serde(default)]
50 pub devices: BTreeMap<String, DeviceConfig>,
51}
52
53impl Default for Config {
54 fn default() -> Self {
55 Self {
56 schema_version: SCHEMA_VERSION,
57 app_settings: AppSettings::default(),
58 selected_device: None,
59 devices: BTreeMap::new(),
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum Appearance {
71 #[default]
73 System,
74 Light,
76 Dark,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[allow(
86 clippy::struct_excessive_bools,
87 reason = "independent on/off user preferences, not a state machine"
88)]
89pub struct AppSettings {
90 #[serde(default)]
96 pub launch_at_login: bool,
97 #[serde(default)]
103 pub check_for_updates: bool,
104 #[serde(default)]
111 pub auto_install_updates: bool,
112 #[serde(default)]
117 pub update_prompt_seen: bool,
118 #[serde(default = "default_true")]
123 pub show_in_menu_bar: bool,
124 #[serde(default = "default_true")]
130 pub auto_download_assets: bool,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub language: Option<String>,
138 #[serde(default = "default_thumbwheel_sensitivity")]
145 pub thumbwheel_sensitivity: i32,
146 #[serde(default)]
148 pub appearance: Appearance,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub theme_light: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub theme_dark: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub ui_radius: Option<u8>,
162}
163
164pub const DEFAULT_THUMBWHEEL_SENSITIVITY: i32 = 14;
168pub const MIN_THUMBWHEEL_SENSITIVITY: i32 = 1;
170pub const MAX_THUMBWHEEL_SENSITIVITY: i32 = 100;
172
173impl AppSettings {
174 #[must_use]
177 pub fn is_default(&self) -> bool {
178 self == &Self::default()
179 }
180}
181
182impl Default for AppSettings {
183 fn default() -> Self {
184 Self {
185 launch_at_login: false,
186 check_for_updates: false,
187 auto_install_updates: false,
188 update_prompt_seen: false,
189 show_in_menu_bar: true,
190 auto_download_assets: true,
191 language: None,
192 thumbwheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY,
193 appearance: Appearance::System,
194 theme_light: None,
195 theme_dark: None,
196 ui_radius: None,
197 }
198 }
199}
200
201fn default_true() -> bool {
204 true
205}
206
207const fn default_thumbwheel_sensitivity() -> i32 {
210 DEFAULT_THUMBWHEEL_SENSITIVITY
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220pub struct Lighting {
221 #[serde(default = "default_lighting_enabled")]
222 pub enabled: bool,
223 #[serde(default = "default_lighting_color")]
225 pub color: String,
226 #[serde(
228 default = "default_lighting_brightness",
229 deserialize_with = "deserialize_brightness"
230 )]
231 pub brightness: u8,
232}
233
234impl Default for Lighting {
235 fn default() -> Self {
236 Self {
237 enabled: default_lighting_enabled(),
238 color: default_lighting_color(),
239 brightness: default_lighting_brightness(),
240 }
241 }
242}
243
244fn default_lighting_enabled() -> bool {
245 true
246}
247
248fn default_lighting_color() -> String {
249 "ffffff".to_string()
250}
251
252fn default_lighting_brightness() -> u8 {
253 100
254}
255
256fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
260where
261 D: serde::Deserializer<'de>,
262{
263 Ok(u8::deserialize(deserializer)?.min(100))
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "snake_case")]
269pub enum WheelMode {
270 Free,
271 Ratchet,
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283pub struct SmartShift {
284 pub mode: WheelMode,
285 pub auto_disengage: u8,
288 pub tunable_torque: u8,
291}
292
293#[derive(Clone, Copy, Debug, PartialEq, Eq)]
301pub enum GestureOwner {
302 Off,
304 Button(ButtonId),
306}
307
308impl Serialize for GestureOwner {
309 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
310 match self {
311 GestureOwner::Off => serializer.serialize_str("Off"),
314 GestureOwner::Button(id) => id.serialize(serializer),
315 }
316 }
317}
318
319fn deserialize_gesture_owner<'de, D>(deserializer: D) -> Result<Option<GestureOwner>, D::Error>
326where
327 D: serde::Deserializer<'de>,
328{
329 let s = String::deserialize(deserializer)?;
330 if s == "Off" {
331 return Ok(Some(GestureOwner::Off));
332 }
333 let button = ButtonId::deserialize(
336 serde::de::value::StrDeserializer::<serde::de::value::Error>::new(&s),
337 )
338 .ok();
339 Ok(button.map(GestureOwner::Button))
340}
341
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct DeviceIdentity {
356 pub display_name: String,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub model_info: Option<DeviceModelInfo>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub codename: Option<String>,
367 pub kind: DeviceKind,
370 pub capabilities: Capabilities,
373}
374
375#[derive(Debug, Clone, Default, Serialize, Deserialize)]
383#[serde(from = "RawDeviceConfig")]
384pub struct DeviceConfig {
385 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub gesture_owner: Option<GestureOwner>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub identity: Option<DeviceIdentity>,
397 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
401 pub bindings: BTreeMap<ButtonId, Binding>,
402 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
409 pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
410 #[serde(default, skip_serializing_if = "Vec::is_empty")]
415 pub dpi_presets: Vec<u32>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub dpi: Option<u32>,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub lighting: Option<Lighting>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub smartshift: Option<SmartShift>,
430 #[serde(default, skip_serializing_if = "is_false")]
437 pub invert_scroll: bool,
438}
439
440#[allow(
443 clippy::trivially_copy_pass_by_ref,
444 reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
445)]
446fn is_false(b: &bool) -> bool {
447 !*b
448}
449
450#[derive(Deserialize)]
455struct RawDeviceConfig {
456 #[serde(default, deserialize_with = "deserialize_gesture_owner")]
461 gesture_owner: Option<GestureOwner>,
462 #[serde(default)]
463 identity: Option<DeviceIdentity>,
464 #[serde(default)]
466 bindings: BTreeMap<ButtonId, Binding>,
467 #[serde(default)]
469 button_bindings: BTreeMap<ButtonId, Action>,
470 #[serde(default)]
472 gesture_bindings: BTreeMap<GestureDirection, Action>,
473 #[serde(default)]
474 per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
475 #[serde(default)]
476 dpi_presets: Vec<u32>,
477 #[serde(default)]
478 dpi: Option<u32>,
479 #[serde(default)]
480 lighting: Option<Lighting>,
481 #[serde(default)]
482 smartshift: Option<SmartShift>,
483 #[serde(default)]
484 invert_scroll: bool,
485}
486
487impl From<RawDeviceConfig> for DeviceConfig {
488 fn from(raw: RawDeviceConfig) -> Self {
489 let mut bindings = raw.bindings; if !raw.gesture_bindings.is_empty() {
497 bindings
498 .entry(ButtonId::GestureButton)
499 .or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
500 }
501 for (button, action) in raw.button_bindings {
502 if button == ButtonId::GestureButton {
513 continue;
514 }
515 bindings.entry(button).or_insert(Binding::Single(action));
516 }
517
518 DeviceConfig {
519 gesture_owner: raw.gesture_owner,
520 identity: raw.identity,
521 bindings,
522 per_app_bindings: raw.per_app_bindings,
523 dpi_presets: raw.dpi_presets,
524 dpi: raw.dpi,
525 lighting: raw.lighting,
526 smartshift: raw.smartshift,
527 invert_scroll: raw.invert_scroll,
528 }
529 }
530}
531
532#[derive(Debug, Error)]
533pub enum ConfigError {
534 #[error("could not resolve config path")]
535 Path(#[from] PathsError),
536 #[error("could not read config at {path}")]
537 Read {
538 path: PathBuf,
539 #[source]
540 source: io::Error,
541 },
542 #[error("could not parse config at {path}")]
543 Parse {
544 path: PathBuf,
545 #[source]
546 source: toml::de::Error,
547 },
548 #[error("could not write config at {path}")]
549 Write {
550 path: PathBuf,
551 #[source]
552 source: io::Error,
553 },
554 #[error("could not serialize config")]
555 Serialize(#[from] toml::ser::Error),
556 #[error("config at {path} has unsupported schema_version {found}")]
557 UnsupportedSchemaVersion { path: PathBuf, found: u32 },
558}
559
560#[allow(
561 clippy::result_large_err,
562 reason = "Config I/O keeps rich parse/write context and is not a hot path"
563)]
564impl Config {
565 pub fn load_or_default() -> Result<Self, ConfigError> {
568 Self::load_from_path(&paths::config_path()?)
569 }
570
571 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
574 match fs::read_to_string(path) {
575 Ok(text) => {
576 let mut config: Self =
577 toml::from_str(&text).map_err(|source| ConfigError::Parse {
578 path: path.to_path_buf(),
579 source,
580 })?;
581 if config.schema_version > SCHEMA_VERSION {
587 return Err(ConfigError::UnsupportedSchemaVersion {
588 path: path.to_path_buf(),
589 found: config.schema_version,
590 });
591 }
592 config.schema_version = SCHEMA_VERSION;
596 Ok(config)
597 }
598 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
599 Err(source) => Err(ConfigError::Read {
600 path: path.to_path_buf(),
601 source,
602 }),
603 }
604 }
605
606 pub fn save_atomic(&self) -> Result<(), ConfigError> {
610 self.save_to_path(&paths::config_path()?)
611 }
612
613 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
615 if let Some(parent) = path.parent() {
616 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
617 path: path.to_path_buf(),
618 source,
619 })?;
620 }
621 let body = toml::to_string_pretty(self)?;
622 write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
623 path: path.to_path_buf(),
624 source,
625 })
626 }
627
628 #[must_use]
631 pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
632 self.devices
633 .get(device_key)
634 .map(|d| d.bindings.clone())
635 .unwrap_or_default()
636 }
637
638 pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
643 self.devices
644 .entry(device_key.to_string())
645 .or_default()
646 .bindings
647 .insert(button, binding);
648 }
649
650 #[must_use]
655 pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
656 match self
657 .devices
658 .get(device_key)
659 .and_then(|d| d.bindings.get(&ButtonId::GestureButton))
660 {
661 Some(Binding::Gesture(map)) => map.clone(),
662 _ => BTreeMap::new(),
663 }
664 }
665
666 pub fn set_gesture_direction(
676 &mut self,
677 device_key: &str,
678 button: ButtonId,
679 direction: GestureDirection,
680 action: Action,
681 ) {
682 if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
683 map.insert(direction, action);
684 }
685 }
686
687 fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
695 let entry = self
696 .devices
697 .entry(device_key.to_string())
698 .or_default()
699 .bindings
700 .entry(button)
701 .or_insert_with(|| default_binding_for(button));
702 entry.upgrade_to_gesture();
703 entry
704 }
705
706 #[must_use]
715 pub fn gesture_owner(&self, device_key: &str) -> Option<ButtonId> {
716 let Some(device) = self.devices.get(device_key) else {
717 return Some(ButtonId::GestureButton);
719 };
720 match device.gesture_owner {
721 Some(GestureOwner::Off) => None,
722 Some(GestureOwner::Button(id)) => Some(id),
723 None => Self::infer_gesture_owner(&device.bindings),
724 }
725 }
726
727 fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
732 if let Some((id, _)) = bindings
734 .iter()
735 .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
736 {
737 return Some(*id);
738 }
739 if matches!(
741 bindings.get(&ButtonId::GestureButton),
742 Some(Binding::Single(_))
743 ) {
744 return None;
745 }
746 Some(ButtonId::GestureButton)
748 }
749
750 pub fn set_gesture_owner(&mut self, device_key: &str, button: ButtonId) {
763 self.devices
764 .entry(device_key.to_string())
765 .or_default()
766 .gesture_owner = Some(GestureOwner::Button(button));
767 self.ensure_gesture_binding(device_key, button)
768 .fill_gesture_defaults();
769 }
770
771 pub fn disable_gestures(&mut self, device_key: &str) {
775 self.devices
776 .entry(device_key.to_string())
777 .or_default()
778 .gesture_owner = Some(GestureOwner::Off);
779 }
780
781 #[must_use]
789 pub fn effective_bindings(
790 &self,
791 device_key: &str,
792 bundle_id: Option<&str>,
793 ) -> BTreeMap<ButtonId, Binding> {
794 let Some(device) = self.devices.get(device_key) else {
795 return BTreeMap::new();
796 };
797 let mut out = device.bindings.clone();
798 if let Some(bid) = bundle_id
799 && let Some(overlay) = device.per_app_bindings.get(bid)
800 {
801 for (k, v) in overlay {
802 out.insert(*k, Binding::Single(v.clone()));
803 }
804 }
805 out
806 }
807
808 pub fn set_per_app_binding(
812 &mut self,
813 device_key: &str,
814 bundle_id: &str,
815 button: ButtonId,
816 action: Option<Action>,
817 ) {
818 let entry = self
819 .devices
820 .entry(device_key.to_string())
821 .or_default()
822 .per_app_bindings
823 .entry(bundle_id.to_string())
824 .or_default();
825 match action {
826 Some(a) => {
827 entry.insert(button, a);
828 }
829 None => {
830 entry.remove(&button);
831 }
832 }
833 if let Some(d) = self.devices.get_mut(device_key) {
834 d.per_app_bindings.retain(|_, m| !m.is_empty());
835 }
836 }
837
838 #[must_use]
840 pub fn selected_device(&self) -> Option<&str> {
841 self.selected_device.as_deref()
842 }
843
844 pub fn set_selected_device(&mut self, key: Option<String>) {
847 self.selected_device = key;
848 }
849
850 #[must_use]
853 pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
854 self.devices
855 .get(device_key)
856 .map(|d| d.dpi_presets.clone())
857 .unwrap_or_default()
858 }
859
860 pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
864 self.devices
865 .entry(device_key.to_string())
866 .or_default()
867 .dpi_presets = presets;
868 }
869
870 #[must_use]
874 pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
875 self.devices
876 .get(device_key)
877 .and_then(|d| d.identity.as_ref())
878 }
879
880 pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
883 self.devices
884 .entry(device_key.to_string())
885 .or_default()
886 .identity = Some(identity);
887 }
888
889 #[must_use]
894 pub fn has_app_override(&self, device_key: &str, app: &str) -> bool {
895 self.devices.get(device_key).is_some_and(|d| {
896 d.per_app_bindings
897 .get(app)
898 .is_some_and(|overlay| !overlay.is_empty())
899 })
900 }
901
902 pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
906 self.devices
907 .iter()
908 .filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
909 }
910
911 #[must_use]
913 pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
914 self.devices
915 .get(device_key)
916 .and_then(|d| d.lighting.clone())
917 }
918
919 pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
921 self.devices
922 .entry(device_key.to_string())
923 .or_default()
924 .lighting = Some(lighting);
925 }
926
927 #[must_use]
929 pub fn dpi(&self, device_key: &str) -> Option<u32> {
930 self.devices.get(device_key).and_then(|d| d.dpi)
931 }
932
933 pub fn set_dpi(&mut self, device_key: &str, dpi: u32) {
936 self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
937 }
938
939 #[must_use]
941 pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
942 self.devices.get(device_key).and_then(|d| d.smartshift)
943 }
944
945 pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
948 self.devices
949 .entry(device_key.to_string())
950 .or_default()
951 .smartshift = Some(smartshift);
952 }
953
954 #[must_use]
957 pub fn invert_scroll(&self, device_key: &str) -> bool {
958 self.devices
959 .get(device_key)
960 .is_some_and(|d| d.invert_scroll)
961 }
962
963 pub fn set_invert_scroll(&mut self, device_key: &str, invert: bool) {
966 self.devices
967 .entry(device_key.to_string())
968 .or_default()
969 .invert_scroll = invert;
970 }
971}
972
973fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
974 let tmp = path.with_extension("toml.tmp");
975 {
976 #[cfg(unix)]
977 {
978 use std::os::unix::fs::OpenOptionsExt;
979 let mut f = fs::OpenOptions::new()
980 .write(true)
981 .create(true)
982 .truncate(true)
983 .mode(0o600)
984 .open(&tmp)?;
985 io::Write::write_all(&mut f, bytes)?;
986 f.sync_all()?;
987 }
988 #[cfg(not(unix))]
989 {
990 let mut f = fs::OpenOptions::new()
991 .write(true)
992 .create(true)
993 .truncate(true)
994 .open(&tmp)?;
995 io::Write::write_all(&mut f, bytes)?;
996 f.sync_all()?;
997 }
998 }
999 fs::rename(&tmp, path)
1000}
1001
1002#[cfg(test)]
1003#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
1004mod tests {
1005 use super::*;
1006 use crate::binding::{default_binding, default_gesture_binding};
1007
1008 fn write_and_read(config: &Config) -> Config {
1009 let dir = tempfile::tempdir().expect("tempdir");
1010 let path = dir.path().join("config.toml");
1011 config.save_to_path(&path).expect("save");
1012 Config::load_from_path(&path).expect("load")
1013 }
1014
1015 #[test]
1016 fn missing_file_yields_default() {
1017 let dir = tempfile::tempdir().expect("tempdir");
1018 let path = dir.path().join("nonexistent.toml");
1019 let cfg = Config::load_from_path(&path).expect("load");
1020 assert_eq!(cfg.schema_version, SCHEMA_VERSION);
1021 assert!(cfg.devices.is_empty());
1022 }
1023
1024 #[test]
1025 fn lighting_roundtrips_per_device() {
1026 let mut cfg = Config::default();
1027 cfg.set_lighting(
1028 "g513",
1029 Lighting {
1030 enabled: true,
1031 color: "00aabb".to_string(),
1032 brightness: 75,
1033 },
1034 );
1035 let restored = write_and_read(&cfg);
1036 assert_eq!(
1037 restored.lighting("g513"),
1038 Some(Lighting {
1039 enabled: true,
1040 color: "00aabb".to_string(),
1041 brightness: 75,
1042 })
1043 );
1044 assert_eq!(restored.lighting("absent"), None);
1045 }
1046
1047 #[test]
1048 fn dpi_roundtrips_per_device() {
1049 let mut cfg = Config::default();
1050 cfg.set_dpi("2b042", 1600);
1051 let restored = write_and_read(&cfg);
1052 assert_eq!(restored.dpi("2b042"), Some(1600));
1053 assert_eq!(restored.dpi("absent"), None);
1054 }
1055
1056 #[test]
1057 fn smartshift_roundtrips_per_device() {
1058 let mut cfg = Config::default();
1059 cfg.set_smartshift(
1060 "2b042",
1061 SmartShift {
1062 mode: WheelMode::Ratchet,
1063 auto_disengage: 16,
1064 tunable_torque: 30,
1065 },
1066 );
1067 let restored = write_and_read(&cfg);
1068 assert_eq!(
1069 restored.smartshift("2b042"),
1070 Some(SmartShift {
1071 mode: WheelMode::Ratchet,
1072 auto_disengage: 16,
1073 tunable_torque: 30,
1074 })
1075 );
1076 assert_eq!(restored.smartshift("absent"), None);
1077 }
1078
1079 #[test]
1080 fn invert_scroll_roundtrips_per_device() {
1081 let mut cfg = Config::default();
1082 assert!(!cfg.invert_scroll("2b042"));
1084 cfg.set_invert_scroll("2b042", true);
1085 let restored = write_and_read(&cfg);
1086 assert!(restored.invert_scroll("2b042"));
1087 assert!(!restored.invert_scroll("absent"));
1088 }
1089
1090 #[test]
1091 fn default_invert_scroll_is_omitted_from_toml() {
1092 let mut cfg = Config::default();
1095 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1096 cfg.set_invert_scroll("2b042", false);
1097 let body = toml::to_string_pretty(&cfg).expect("serialize");
1098 assert!(
1099 !body.contains("invert_scroll"),
1100 "default invert_scroll should be omitted: {body}"
1101 );
1102 }
1103
1104 #[test]
1105 fn bindings_roundtrip_per_device() {
1106 let mut cfg = Config::default();
1107 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1108 cfg.set_binding(
1109 "2b042",
1110 ButtonId::DpiToggle,
1111 Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
1112 modifiers: crate::binding::KeyCombo::MOD_CMD,
1113 key_code: 0x23, display: "⌘P".into(),
1115 })),
1116 );
1117 cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
1118
1119 let parsed = write_and_read(&cfg);
1120
1121 let a = parsed.bindings_for("2b042");
1123 assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
1124 assert_eq!(
1125 a.get(&ButtonId::DpiToggle),
1126 Some(&Binding::Single(Action::CustomShortcut(
1127 crate::binding::KeyCombo {
1128 modifiers: crate::binding::KeyCombo::MOD_CMD,
1129 key_code: 0x23,
1130 display: "⌘P".into(),
1131 }
1132 )))
1133 );
1134
1135 let b = parsed.bindings_for("4082d");
1136 assert_eq!(
1137 b.get(&ButtonId::Back),
1138 Some(&Binding::Single(Action::Paste))
1139 );
1140 assert_eq!(b.len(), 1, "device b should only see its own bindings");
1141
1142 assert!(parsed.bindings_for("deadbeef").is_empty());
1144 }
1145
1146 #[test]
1147 fn human_readable_toml_layout() {
1148 let mut cfg = Config::default();
1149 cfg.set_binding(
1150 "2b042",
1151 ButtonId::Back,
1152 Binding::Single(Action::BrowserBack),
1153 );
1154 let body = toml::to_string_pretty(&cfg).expect("serialize");
1155
1156 assert!(body.contains("schema_version = 3"), "got: {body}");
1160 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1161 assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
1164 }
1165
1166 #[test]
1167 fn dpi_presets_roundtrip_per_device() {
1168 let mut cfg = Config::default();
1169 cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
1170 cfg.set_dpi_presets("4082d", vec![400, 1600]);
1171
1172 let parsed = write_and_read(&cfg);
1173
1174 assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
1175 assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
1176 assert!(parsed.dpi_presets("unknown").is_empty());
1177 }
1178
1179 #[test]
1180 fn empty_dpi_presets_skip_serialization() {
1181 let mut cfg = Config::default();
1182 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1184 cfg.set_dpi_presets("2b042", vec![800]);
1185 cfg.set_dpi_presets("2b042", vec![]); let body = toml::to_string_pretty(&cfg).expect("serialize");
1188 assert!(
1189 !body.contains("dpi_presets"),
1190 "empty dpi_presets should be omitted: {body}"
1191 );
1192 }
1193
1194 #[test]
1195 fn device_identity_roundtrips_and_is_iterable() {
1196 use crate::device::{Capabilities, DeviceKind};
1197
1198 let mut cfg = Config::default();
1199 let mouse = DeviceIdentity {
1200 display_name: "MX Master 3S".to_string(),
1201 model_info: None,
1202 codename: None,
1203 kind: DeviceKind::Mouse,
1204 capabilities: Capabilities {
1205 buttons: true,
1206 pointer: true,
1207 lighting: false,
1208 scroll_inversion: false,
1209 },
1210 };
1211 cfg.set_device_identity("2b034", mouse.clone());
1212 cfg.set_binding(
1214 "2b034",
1215 ButtonId::Back,
1216 Binding::Single(Action::BrowserBack),
1217 );
1218
1219 let parsed = write_and_read(&cfg);
1220 assert_eq!(parsed.device_identity("2b034"), Some(&mouse));
1221 assert_eq!(parsed.device_identity("absent"), None);
1222 assert_eq!(
1223 parsed.bindings_for("2b034").get(&ButtonId::Back),
1224 Some(&Binding::Single(Action::BrowserBack)),
1225 "identity must coexist with bindings on the same device block"
1226 );
1227 assert_eq!(
1228 parsed.known_identities().collect::<Vec<_>>(),
1229 vec![("2b034", &mouse)]
1230 );
1231 }
1232
1233 #[test]
1234 fn selected_device_roundtrips() {
1235 let mut cfg = Config::default();
1236 assert_eq!(cfg.selected_device(), None);
1237 cfg.set_selected_device(Some("2b042".into()));
1238 let parsed = write_and_read(&cfg);
1239 assert_eq!(parsed.selected_device(), Some("2b042"));
1240 }
1241
1242 #[test]
1243 fn per_app_overlay_takes_precedence() {
1244 let mut cfg = Config::default();
1245 cfg.set_binding(
1246 "2b042",
1247 ButtonId::Back,
1248 Binding::Single(Action::BrowserBack),
1249 );
1250 cfg.set_binding(
1251 "2b042",
1252 ButtonId::Forward,
1253 Binding::Single(Action::BrowserForward),
1254 );
1255 cfg.set_per_app_binding(
1256 "2b042",
1257 "com.microsoft.VSCode",
1258 ButtonId::Back,
1259 Some(Action::Undo),
1260 );
1261
1262 let global = cfg.effective_bindings("2b042", None);
1264 assert_eq!(
1265 global.get(&ButtonId::Back),
1266 Some(&Binding::Single(Action::BrowserBack))
1267 );
1268 assert_eq!(
1269 global.get(&ButtonId::Forward),
1270 Some(&Binding::Single(Action::BrowserForward))
1271 );
1272
1273 let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
1275 assert_eq!(
1276 vscode.get(&ButtonId::Back),
1277 Some(&Binding::Single(Action::Undo))
1278 );
1279 assert_eq!(
1280 vscode.get(&ButtonId::Forward),
1281 Some(&Binding::Single(Action::BrowserForward))
1282 );
1283
1284 let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
1286 assert_eq!(
1287 other.get(&ButtonId::Back),
1288 Some(&Binding::Single(Action::BrowserBack))
1289 );
1290 }
1291
1292 #[test]
1293 fn per_app_binding_removal_prunes_empty_app() {
1294 let mut cfg = Config::default();
1295 cfg.set_per_app_binding(
1296 "2b042",
1297 "com.example.App",
1298 ButtonId::Back,
1299 Some(Action::Copy),
1300 );
1301 cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
1302 assert!(
1303 cfg.devices["2b042"].per_app_bindings.is_empty(),
1304 "removing last override should prune the app entry"
1305 );
1306 }
1307
1308 #[test]
1309 fn app_settings_default_omits_block() {
1310 let cfg = Config::default();
1311 let body = toml::to_string_pretty(&cfg).expect("serialize");
1312 assert!(
1313 !body.contains("app_settings"),
1314 "default app_settings should be omitted: {body}"
1315 );
1316 }
1317
1318 #[test]
1319 fn app_settings_launch_at_login_roundtrips() {
1320 let mut cfg = Config::default();
1321 cfg.app_settings.launch_at_login = true;
1322 let parsed = write_and_read(&cfg);
1323 assert!(parsed.app_settings.launch_at_login);
1324 }
1325
1326 #[test]
1327 fn cleared_selected_device_omits_field() {
1328 let mut cfg = Config::default();
1329 cfg.set_selected_device(Some("2b042".into()));
1330 cfg.set_selected_device(None);
1331 let body = toml::to_string_pretty(&cfg).expect("serialize");
1332 assert!(
1333 !body.contains("selected_device"),
1334 "cleared selection should not appear: {body}"
1335 );
1336 }
1337
1338 #[test]
1339 fn empty_device_block_is_skipped_in_output() {
1340 let mut cfg = Config::default();
1343 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1344 cfg.devices
1345 .get_mut("2b042")
1346 .expect("entry")
1347 .bindings
1348 .clear();
1349 let body = toml::to_string_pretty(&cfg).expect("serialize");
1350 assert!(
1351 !body.contains("Back"),
1352 "cleared bindings should not appear: {body}"
1353 );
1354 }
1355
1356 #[test]
1357 fn migrates_v1_button_and_gesture_bindings() {
1358 let v1 = "\
1360schema_version = 1
1361
1362[devices.2b042.button_bindings]
1363Back = \"BrowserBack\"
1364
1365[devices.2b042.gesture_bindings]
1366Up = \"Copy\"
1367Click = \"Paste\"
1368";
1369 let dir = tempfile::tempdir().expect("tempdir");
1370 let path = dir.path().join("config.toml");
1371 fs::write(&path, v1).expect("write");
1372
1373 let cfg = Config::load_from_path(&path).expect("load v1");
1375 let bindings = cfg.bindings_for("2b042");
1376 assert_eq!(
1377 bindings.get(&ButtonId::Back),
1378 Some(&Binding::Single(Action::BrowserBack))
1379 );
1380 let mut gesture = BTreeMap::new();
1381 gesture.insert(GestureDirection::Up, Action::Copy);
1382 gesture.insert(GestureDirection::Click, Action::Paste);
1383 assert_eq!(
1384 bindings.get(&ButtonId::GestureButton),
1385 Some(&Binding::Gesture(gesture))
1386 );
1387
1388 let body = toml::to_string_pretty(&cfg).expect("serialize");
1391 assert!(body.contains("schema_version = 3"), "got: {body}");
1392 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1393 assert!(!body.contains("button_bindings"), "got: {body}");
1394 assert!(!body.contains("gesture_bindings"), "got: {body}");
1395 }
1396
1397 #[test]
1398 fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
1399 let v1 = "\
1404schema_version = 1
1405
1406[devices.2b042.button_bindings]
1407GestureButton = \"MissionControl\"
1408
1409[devices.2b042.gesture_bindings]
1410Up = \"Copy\"
1411Down = \"Paste\"
1412";
1413 let dir = tempfile::tempdir().expect("tempdir");
1414 let path = dir.path().join("config.toml");
1415 fs::write(&path, v1).expect("write");
1416
1417 let cfg = Config::load_from_path(&path).expect("load v1");
1418 let mut gesture = BTreeMap::new();
1419 gesture.insert(GestureDirection::Up, Action::Copy);
1420 gesture.insert(GestureDirection::Down, Action::Paste);
1421 assert_eq!(
1422 cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
1423 Some(&Binding::Gesture(gesture)),
1424 "gesture map must win over the legacy single GestureButton entry"
1425 );
1426 }
1427
1428 #[test]
1429 fn migration_drops_vestigial_lone_gesture_button_single() {
1430 let v1 = "\
1437schema_version = 1
1438
1439[devices.2b042.button_bindings]
1440GestureButton = \"MissionControl\"
1441Back = \"BrowserBack\"
1442";
1443 let dir = tempfile::tempdir().expect("tempdir");
1444 let path = dir.path().join("config.toml");
1445 fs::write(&path, v1).expect("write");
1446
1447 let bindings = Config::load_from_path(&path)
1448 .expect("load v1")
1449 .bindings_for("2b042");
1450 assert_eq!(
1452 bindings.get(&ButtonId::Back),
1453 Some(&Binding::Single(Action::BrowserBack))
1454 );
1455 assert_eq!(bindings.get(&ButtonId::GestureButton), None);
1458 }
1459
1460 #[test]
1461 fn rejects_newer_schema_version_but_accepts_v1() {
1462 let dir = tempfile::tempdir().expect("tempdir");
1465 let path = dir.path().join("config.toml");
1466 fs::write(&path, "schema_version = 99\n").expect("write");
1467 assert!(matches!(
1468 Config::load_from_path(&path).expect_err("v99 should fail"),
1469 ConfigError::UnsupportedSchemaVersion { found: 99, .. }
1470 ));
1471
1472 fs::write(&path, "schema_version = 1\n").expect("write");
1473 assert!(
1474 Config::load_from_path(&path).is_ok(),
1475 "v1 should still load"
1476 );
1477 }
1478
1479 #[test]
1480 fn set_gesture_direction_upgrades_single_to_gesture() {
1481 let mut cfg = Config::default();
1482 cfg.set_binding(
1484 "2b042",
1485 ButtonId::Back,
1486 Binding::Single(Action::BrowserBack),
1487 );
1488 cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
1489
1490 match cfg.bindings_for("2b042").get(&ButtonId::Back) {
1491 Some(Binding::Gesture(map)) => {
1492 assert_eq!(
1494 map.get(&GestureDirection::Click),
1495 Some(&Action::BrowserBack)
1496 );
1497 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1498 }
1499 other => panic!("expected Gesture after upgrade, got {other:?}"),
1500 }
1501 }
1502
1503 #[test]
1504 fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
1505 let mut cfg = Config::default();
1509 cfg.set_gesture_direction(
1510 "2b042",
1511 ButtonId::GestureButton,
1512 GestureDirection::Up,
1513 Action::Copy,
1514 );
1515
1516 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1517 Some(Binding::Gesture(map)) => {
1518 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1519 assert_eq!(
1520 map.get(&GestureDirection::Click),
1521 Some(&crate::binding::default_gesture_binding(
1522 GestureDirection::Click
1523 )),
1524 "a fresh gesture button must seed a Click from its default"
1525 );
1526 }
1527 other => panic!("expected Gesture, got {other:?}"),
1528 }
1529 }
1530
1531 #[test]
1532 fn gesture_owner_defaults_to_hidpp_button_yields_to_oshook_and_can_be_off() {
1533 let mut cfg = Config::default();
1534 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1536
1537 cfg.set_gesture_direction(
1539 "2b042",
1540 ButtonId::GestureButton,
1541 GestureDirection::Up,
1542 Action::MissionControl,
1543 );
1544 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1545
1546 cfg.set_binding(
1548 "2b042",
1549 ButtonId::Forward,
1550 Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
1551 );
1552 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
1553
1554 let mut off = Config::default();
1556 off.disable_gestures("2b042");
1557 assert_eq!(off.gesture_owner("2b042"), None);
1558 }
1559
1560 #[test]
1561 fn set_gesture_owner_records_owner_without_destroying_other_maps() {
1562 let mut cfg = Config::default();
1563 cfg.set_gesture_direction(
1565 "2b042",
1566 ButtonId::GestureButton,
1567 GestureDirection::Up,
1568 Action::Copy,
1569 );
1570 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1571
1572 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
1575 cfg.set_gesture_owner("2b042", ButtonId::Back);
1576 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
1577
1578 let bindings = cfg.bindings_for("2b042");
1579 match bindings.get(&ButtonId::Back) {
1582 Some(Binding::Gesture(map)) => {
1583 assert_eq!(
1584 map.get(&GestureDirection::Click),
1585 Some(&Action::BrowserBack)
1586 );
1587 assert_eq!(
1588 map.get(&GestureDirection::Up),
1589 Some(&default_gesture_binding(GestureDirection::Up)),
1590 "a promoted button gets full default arms"
1591 );
1592 }
1593 other => panic!("expected Back to be a gesture binding, got {other:?}"),
1594 }
1595 match bindings.get(&ButtonId::GestureButton) {
1597 Some(Binding::Gesture(map)) => {
1598 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1599 }
1600 other => panic!("expected the HID++ gesture button map preserved, got {other:?}"),
1601 }
1602
1603 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1606 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1607 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1608 Some(Binding::Gesture(map)) => {
1609 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1610 }
1611 other => panic!("expected preserved gesture map, got {other:?}"),
1612 }
1613 }
1614
1615 #[test]
1616 fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
1617 let mut cfg = Config::default();
1618 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1620 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1621 Some(Binding::Gesture(map)) => {
1622 for dir in GestureDirection::ALL {
1623 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1624 }
1625 }
1626 other => panic!("expected full default gesture map, got {other:?}"),
1627 }
1628
1629 cfg.set_gesture_owner("2b042", ButtonId::Forward);
1633 match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
1634 Some(Binding::Gesture(map)) => {
1635 assert_eq!(
1636 map.get(&GestureDirection::Click),
1637 Some(&default_binding(ButtonId::Forward))
1638 );
1639 for dir in [
1640 GestureDirection::Up,
1641 GestureDirection::Down,
1642 GestureDirection::Left,
1643 GestureDirection::Right,
1644 ] {
1645 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1646 }
1647 }
1648 other => panic!("expected full gesture map for Forward, got {other:?}"),
1649 }
1650 }
1651
1652 #[test]
1653 fn disable_gestures_turns_off_without_destroying_maps() {
1654 let mut cfg = Config::default();
1655 cfg.set_gesture_direction(
1656 "2b042",
1657 ButtonId::GestureButton,
1658 GestureDirection::Up,
1659 Action::Copy,
1660 );
1661 cfg.disable_gestures("2b042");
1662 assert_eq!(cfg.gesture_owner("2b042"), None);
1665 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1666 Some(Binding::Gesture(map)) => {
1667 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1668 }
1669 other => panic!("expected the gesture map preserved while off, got {other:?}"),
1670 }
1671 }
1672
1673 #[test]
1674 fn gesture_owner_field_roundtrips_as_a_scalar() {
1675 let mut cfg = Config::default();
1676 cfg.set_gesture_owner("2b042", ButtonId::Back); cfg.disable_gestures("4082d"); let parsed = write_and_read(&cfg);
1680 assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
1681 assert_eq!(parsed.gesture_owner("4082d"), None);
1682
1683 let body = toml::to_string_pretty(&cfg).expect("serialize");
1686 assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
1687 assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
1688 }
1689
1690 #[test]
1691 fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
1692 let toml = "\
1696schema_version = 2
1697
1698[devices.2b042]
1699gesture_owner = \"bogus\"
1700
1701[devices.2b042.bindings]
1702Back = \"Copy\"
1703";
1704 let dir = tempfile::tempdir().expect("tempdir");
1705 let path = dir.path().join("config.toml");
1706 fs::write(&path, toml).expect("write");
1707
1708 let cfg =
1709 Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
1710 assert_eq!(
1712 cfg.bindings_for("2b042").get(&ButtonId::Back),
1713 Some(&Binding::Single(Action::Copy))
1714 );
1715 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1717 }
1718}