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::paths::{self, PathsError};
21
22pub const SCHEMA_VERSION: u32 = 2;
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Config {
36 pub schema_version: u32,
37 #[serde(default, skip_serializing_if = "AppSettings::is_default")]
39 pub app_settings: AppSettings,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub selected_device: Option<String>,
45 #[serde(default)]
46 pub devices: BTreeMap<String, DeviceConfig>,
47}
48
49impl Default for Config {
50 fn default() -> Self {
51 Self {
52 schema_version: SCHEMA_VERSION,
53 app_settings: AppSettings::default(),
54 selected_device: None,
55 devices: BTreeMap::new(),
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[allow(
66 clippy::struct_excessive_bools,
67 reason = "independent on/off user preferences, not a state machine"
68)]
69pub struct AppSettings {
70 #[serde(default)]
76 pub launch_at_login: bool,
77 #[serde(default)]
83 pub check_for_updates: bool,
84 #[serde(default)]
89 pub update_prompt_seen: bool,
90 #[serde(default = "default_true")]
95 pub show_in_menu_bar: bool,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub language: Option<String>,
103 #[serde(default = "default_thumbwheel_sensitivity")]
110 pub thumbwheel_sensitivity: i32,
111}
112
113pub const DEFAULT_THUMBWHEEL_SENSITIVITY: i32 = 14;
117pub const MIN_THUMBWHEEL_SENSITIVITY: i32 = 1;
119pub const MAX_THUMBWHEEL_SENSITIVITY: i32 = 100;
121
122impl AppSettings {
123 #[must_use]
126 pub fn is_default(&self) -> bool {
127 self == &Self::default()
128 }
129}
130
131impl Default for AppSettings {
132 fn default() -> Self {
133 Self {
134 launch_at_login: false,
135 check_for_updates: false,
136 update_prompt_seen: false,
137 show_in_menu_bar: true,
138 language: None,
139 thumbwheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY,
140 }
141 }
142}
143
144fn default_true() -> bool {
147 true
148}
149
150const fn default_thumbwheel_sensitivity() -> i32 {
153 DEFAULT_THUMBWHEEL_SENSITIVITY
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct Lighting {
164 #[serde(default = "default_lighting_enabled")]
165 pub enabled: bool,
166 #[serde(default = "default_lighting_color")]
168 pub color: String,
169 #[serde(
171 default = "default_lighting_brightness",
172 deserialize_with = "deserialize_brightness"
173 )]
174 pub brightness: u8,
175}
176
177impl Default for Lighting {
178 fn default() -> Self {
179 Self {
180 enabled: default_lighting_enabled(),
181 color: default_lighting_color(),
182 brightness: default_lighting_brightness(),
183 }
184 }
185}
186
187fn default_lighting_enabled() -> bool {
188 true
189}
190
191fn default_lighting_color() -> String {
192 "ffffff".to_string()
193}
194
195fn default_lighting_brightness() -> u8 {
196 100
197}
198
199fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
203where
204 D: serde::Deserializer<'de>,
205{
206 Ok(u8::deserialize(deserializer)?.min(100))
207}
208
209#[derive(Clone, Copy, Debug, PartialEq, Eq)]
217pub enum GestureOwner {
218 Off,
220 Button(ButtonId),
222}
223
224impl Serialize for GestureOwner {
225 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
226 match self {
227 GestureOwner::Off => serializer.serialize_str("Off"),
230 GestureOwner::Button(id) => id.serialize(serializer),
231 }
232 }
233}
234
235fn deserialize_gesture_owner<'de, D>(deserializer: D) -> Result<Option<GestureOwner>, D::Error>
242where
243 D: serde::Deserializer<'de>,
244{
245 let s = String::deserialize(deserializer)?;
246 if s == "Off" {
247 return Ok(Some(GestureOwner::Off));
248 }
249 let button = ButtonId::deserialize(
252 serde::de::value::StrDeserializer::<serde::de::value::Error>::new(&s),
253 )
254 .ok();
255 Ok(button.map(GestureOwner::Button))
256}
257
258#[derive(Debug, Clone, Default, Serialize, Deserialize)]
266#[serde(from = "RawDeviceConfig")]
267pub struct DeviceConfig {
268 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub gesture_owner: Option<GestureOwner>,
274 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
278 pub bindings: BTreeMap<ButtonId, Binding>,
279 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
286 pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
287 #[serde(default, skip_serializing_if = "Vec::is_empty")]
292 pub dpi_presets: Vec<u32>,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub lighting: Option<Lighting>,
297}
298
299#[derive(Deserialize)]
304struct RawDeviceConfig {
305 #[serde(default, deserialize_with = "deserialize_gesture_owner")]
310 gesture_owner: Option<GestureOwner>,
311 #[serde(default)]
313 bindings: BTreeMap<ButtonId, Binding>,
314 #[serde(default)]
316 button_bindings: BTreeMap<ButtonId, Action>,
317 #[serde(default)]
319 gesture_bindings: BTreeMap<GestureDirection, Action>,
320 #[serde(default)]
321 per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
322 #[serde(default)]
323 dpi_presets: Vec<u32>,
324 #[serde(default)]
325 lighting: Option<Lighting>,
326}
327
328impl From<RawDeviceConfig> for DeviceConfig {
329 fn from(raw: RawDeviceConfig) -> Self {
330 let mut bindings = raw.bindings; if !raw.gesture_bindings.is_empty() {
338 bindings
339 .entry(ButtonId::GestureButton)
340 .or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
341 }
342 for (button, action) in raw.button_bindings {
343 if button == ButtonId::GestureButton {
354 continue;
355 }
356 bindings.entry(button).or_insert(Binding::Single(action));
357 }
358
359 DeviceConfig {
360 gesture_owner: raw.gesture_owner,
361 bindings,
362 per_app_bindings: raw.per_app_bindings,
363 dpi_presets: raw.dpi_presets,
364 lighting: raw.lighting,
365 }
366 }
367}
368
369#[derive(Debug, Error)]
370pub enum ConfigError {
371 #[error("could not resolve config path")]
372 Path(#[from] PathsError),
373 #[error("could not read config at {path}")]
374 Read {
375 path: PathBuf,
376 #[source]
377 source: io::Error,
378 },
379 #[error("could not parse config at {path}")]
380 Parse {
381 path: PathBuf,
382 #[source]
383 source: toml::de::Error,
384 },
385 #[error("could not write config at {path}")]
386 Write {
387 path: PathBuf,
388 #[source]
389 source: io::Error,
390 },
391 #[error("could not serialize config")]
392 Serialize(#[from] toml::ser::Error),
393 #[error("config at {path} has unsupported schema_version {found}")]
394 UnsupportedSchemaVersion { path: PathBuf, found: u32 },
395}
396
397#[allow(
398 clippy::result_large_err,
399 reason = "Config I/O keeps rich parse/write context and is not a hot path"
400)]
401impl Config {
402 pub fn load_or_default() -> Result<Self, ConfigError> {
405 Self::load_from_path(&paths::config_path()?)
406 }
407
408 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
411 match fs::read_to_string(path) {
412 Ok(text) => {
413 let mut config: Self =
414 toml::from_str(&text).map_err(|source| ConfigError::Parse {
415 path: path.to_path_buf(),
416 source,
417 })?;
418 if config.schema_version > SCHEMA_VERSION {
424 return Err(ConfigError::UnsupportedSchemaVersion {
425 path: path.to_path_buf(),
426 found: config.schema_version,
427 });
428 }
429 config.schema_version = SCHEMA_VERSION;
433 Ok(config)
434 }
435 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
436 Err(source) => Err(ConfigError::Read {
437 path: path.to_path_buf(),
438 source,
439 }),
440 }
441 }
442
443 pub fn save_atomic(&self) -> Result<(), ConfigError> {
447 self.save_to_path(&paths::config_path()?)
448 }
449
450 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
452 if let Some(parent) = path.parent() {
453 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
454 path: path.to_path_buf(),
455 source,
456 })?;
457 }
458 let body = toml::to_string_pretty(self)?;
459 write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
460 path: path.to_path_buf(),
461 source,
462 })
463 }
464
465 #[must_use]
468 pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
469 self.devices
470 .get(device_key)
471 .map(|d| d.bindings.clone())
472 .unwrap_or_default()
473 }
474
475 pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
480 self.devices
481 .entry(device_key.to_string())
482 .or_default()
483 .bindings
484 .insert(button, binding);
485 }
486
487 #[must_use]
492 pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
493 match self
494 .devices
495 .get(device_key)
496 .and_then(|d| d.bindings.get(&ButtonId::GestureButton))
497 {
498 Some(Binding::Gesture(map)) => map.clone(),
499 _ => BTreeMap::new(),
500 }
501 }
502
503 pub fn set_gesture_direction(
513 &mut self,
514 device_key: &str,
515 button: ButtonId,
516 direction: GestureDirection,
517 action: Action,
518 ) {
519 if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
520 map.insert(direction, action);
521 }
522 }
523
524 fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
532 let entry = self
533 .devices
534 .entry(device_key.to_string())
535 .or_default()
536 .bindings
537 .entry(button)
538 .or_insert_with(|| default_binding_for(button));
539 entry.upgrade_to_gesture();
540 entry
541 }
542
543 #[must_use]
552 pub fn gesture_owner(&self, device_key: &str) -> Option<ButtonId> {
553 let Some(device) = self.devices.get(device_key) else {
554 return Some(ButtonId::GestureButton);
556 };
557 match device.gesture_owner {
558 Some(GestureOwner::Off) => None,
559 Some(GestureOwner::Button(id)) => Some(id),
560 None => Self::infer_gesture_owner(&device.bindings),
561 }
562 }
563
564 fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
569 if let Some((id, _)) = bindings
571 .iter()
572 .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
573 {
574 return Some(*id);
575 }
576 if matches!(
578 bindings.get(&ButtonId::GestureButton),
579 Some(Binding::Single(_))
580 ) {
581 return None;
582 }
583 Some(ButtonId::GestureButton)
585 }
586
587 pub fn set_gesture_owner(&mut self, device_key: &str, button: ButtonId) {
599 self.devices
600 .entry(device_key.to_string())
601 .or_default()
602 .gesture_owner = Some(GestureOwner::Button(button));
603 self.ensure_gesture_binding(device_key, button)
604 .fill_gesture_defaults();
605 }
606
607 pub fn disable_gestures(&mut self, device_key: &str) {
611 self.devices
612 .entry(device_key.to_string())
613 .or_default()
614 .gesture_owner = Some(GestureOwner::Off);
615 }
616
617 #[must_use]
625 pub fn effective_bindings(
626 &self,
627 device_key: &str,
628 bundle_id: Option<&str>,
629 ) -> BTreeMap<ButtonId, Binding> {
630 let Some(device) = self.devices.get(device_key) else {
631 return BTreeMap::new();
632 };
633 let mut out = device.bindings.clone();
634 if let Some(bid) = bundle_id
635 && let Some(overlay) = device.per_app_bindings.get(bid)
636 {
637 for (k, v) in overlay {
638 out.insert(*k, Binding::Single(v.clone()));
639 }
640 }
641 out
642 }
643
644 pub fn set_per_app_binding(
648 &mut self,
649 device_key: &str,
650 bundle_id: &str,
651 button: ButtonId,
652 action: Option<Action>,
653 ) {
654 let entry = self
655 .devices
656 .entry(device_key.to_string())
657 .or_default()
658 .per_app_bindings
659 .entry(bundle_id.to_string())
660 .or_default();
661 match action {
662 Some(a) => {
663 entry.insert(button, a);
664 }
665 None => {
666 entry.remove(&button);
667 }
668 }
669 if let Some(d) = self.devices.get_mut(device_key) {
670 d.per_app_bindings.retain(|_, m| !m.is_empty());
671 }
672 }
673
674 #[must_use]
676 pub fn selected_device(&self) -> Option<&str> {
677 self.selected_device.as_deref()
678 }
679
680 pub fn set_selected_device(&mut self, key: Option<String>) {
683 self.selected_device = key;
684 }
685
686 #[must_use]
689 pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
690 self.devices
691 .get(device_key)
692 .map(|d| d.dpi_presets.clone())
693 .unwrap_or_default()
694 }
695
696 pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
700 self.devices
701 .entry(device_key.to_string())
702 .or_default()
703 .dpi_presets = presets;
704 }
705
706 #[must_use]
708 pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
709 self.devices
710 .get(device_key)
711 .and_then(|d| d.lighting.clone())
712 }
713
714 pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
716 self.devices
717 .entry(device_key.to_string())
718 .or_default()
719 .lighting = Some(lighting);
720 }
721}
722
723fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
724 let tmp = path.with_extension("toml.tmp");
725 {
726 #[cfg(unix)]
727 {
728 use std::os::unix::fs::OpenOptionsExt;
729 let mut f = fs::OpenOptions::new()
730 .write(true)
731 .create(true)
732 .truncate(true)
733 .mode(0o600)
734 .open(&tmp)?;
735 io::Write::write_all(&mut f, bytes)?;
736 f.sync_all()?;
737 }
738 #[cfg(not(unix))]
739 {
740 let mut f = fs::OpenOptions::new()
741 .write(true)
742 .create(true)
743 .truncate(true)
744 .open(&tmp)?;
745 io::Write::write_all(&mut f, bytes)?;
746 f.sync_all()?;
747 }
748 }
749 fs::rename(&tmp, path)
750}
751
752#[cfg(test)]
753#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
754mod tests {
755 use super::*;
756 use crate::binding::{default_binding, default_gesture_binding};
757
758 fn write_and_read(config: &Config) -> Config {
759 let dir = tempfile::tempdir().expect("tempdir");
760 let path = dir.path().join("config.toml");
761 config.save_to_path(&path).expect("save");
762 Config::load_from_path(&path).expect("load")
763 }
764
765 #[test]
766 fn missing_file_yields_default() {
767 let dir = tempfile::tempdir().expect("tempdir");
768 let path = dir.path().join("nonexistent.toml");
769 let cfg = Config::load_from_path(&path).expect("load");
770 assert_eq!(cfg.schema_version, SCHEMA_VERSION);
771 assert!(cfg.devices.is_empty());
772 }
773
774 #[test]
775 fn lighting_roundtrips_per_device() {
776 let mut cfg = Config::default();
777 cfg.set_lighting(
778 "g513",
779 Lighting {
780 enabled: true,
781 color: "00aabb".to_string(),
782 brightness: 75,
783 },
784 );
785 let restored = write_and_read(&cfg);
786 assert_eq!(
787 restored.lighting("g513"),
788 Some(Lighting {
789 enabled: true,
790 color: "00aabb".to_string(),
791 brightness: 75,
792 })
793 );
794 assert_eq!(restored.lighting("absent"), None);
795 }
796
797 #[test]
798 fn bindings_roundtrip_per_device() {
799 let mut cfg = Config::default();
800 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
801 cfg.set_binding(
802 "2b042",
803 ButtonId::DpiToggle,
804 Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
805 modifiers: crate::binding::KeyCombo::MOD_CMD,
806 key_code: 0x23, display: "⌘P".into(),
808 })),
809 );
810 cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
811
812 let parsed = write_and_read(&cfg);
813
814 let a = parsed.bindings_for("2b042");
816 assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
817 assert_eq!(
818 a.get(&ButtonId::DpiToggle),
819 Some(&Binding::Single(Action::CustomShortcut(
820 crate::binding::KeyCombo {
821 modifiers: crate::binding::KeyCombo::MOD_CMD,
822 key_code: 0x23,
823 display: "⌘P".into(),
824 }
825 )))
826 );
827
828 let b = parsed.bindings_for("4082d");
829 assert_eq!(
830 b.get(&ButtonId::Back),
831 Some(&Binding::Single(Action::Paste))
832 );
833 assert_eq!(b.len(), 1, "device b should only see its own bindings");
834
835 assert!(parsed.bindings_for("deadbeef").is_empty());
837 }
838
839 #[test]
840 fn human_readable_toml_layout() {
841 let mut cfg = Config::default();
842 cfg.set_binding(
843 "2b042",
844 ButtonId::Back,
845 Binding::Single(Action::BrowserBack),
846 );
847 let body = toml::to_string_pretty(&cfg).expect("serialize");
848
849 assert!(body.contains("schema_version = 2"), "got: {body}");
853 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
854 assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
857 }
858
859 #[test]
860 fn dpi_presets_roundtrip_per_device() {
861 let mut cfg = Config::default();
862 cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
863 cfg.set_dpi_presets("4082d", vec![400, 1600]);
864
865 let parsed = write_and_read(&cfg);
866
867 assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
868 assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
869 assert!(parsed.dpi_presets("unknown").is_empty());
870 }
871
872 #[test]
873 fn empty_dpi_presets_skip_serialization() {
874 let mut cfg = Config::default();
875 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
877 cfg.set_dpi_presets("2b042", vec![800]);
878 cfg.set_dpi_presets("2b042", vec![]); let body = toml::to_string_pretty(&cfg).expect("serialize");
881 assert!(
882 !body.contains("dpi_presets"),
883 "empty dpi_presets should be omitted: {body}"
884 );
885 }
886
887 #[test]
888 fn selected_device_roundtrips() {
889 let mut cfg = Config::default();
890 assert_eq!(cfg.selected_device(), None);
891 cfg.set_selected_device(Some("2b042".into()));
892 let parsed = write_and_read(&cfg);
893 assert_eq!(parsed.selected_device(), Some("2b042"));
894 }
895
896 #[test]
897 fn per_app_overlay_takes_precedence() {
898 let mut cfg = Config::default();
899 cfg.set_binding(
900 "2b042",
901 ButtonId::Back,
902 Binding::Single(Action::BrowserBack),
903 );
904 cfg.set_binding(
905 "2b042",
906 ButtonId::Forward,
907 Binding::Single(Action::BrowserForward),
908 );
909 cfg.set_per_app_binding(
910 "2b042",
911 "com.microsoft.VSCode",
912 ButtonId::Back,
913 Some(Action::Undo),
914 );
915
916 let global = cfg.effective_bindings("2b042", None);
918 assert_eq!(
919 global.get(&ButtonId::Back),
920 Some(&Binding::Single(Action::BrowserBack))
921 );
922 assert_eq!(
923 global.get(&ButtonId::Forward),
924 Some(&Binding::Single(Action::BrowserForward))
925 );
926
927 let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
929 assert_eq!(
930 vscode.get(&ButtonId::Back),
931 Some(&Binding::Single(Action::Undo))
932 );
933 assert_eq!(
934 vscode.get(&ButtonId::Forward),
935 Some(&Binding::Single(Action::BrowserForward))
936 );
937
938 let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
940 assert_eq!(
941 other.get(&ButtonId::Back),
942 Some(&Binding::Single(Action::BrowserBack))
943 );
944 }
945
946 #[test]
947 fn per_app_binding_removal_prunes_empty_app() {
948 let mut cfg = Config::default();
949 cfg.set_per_app_binding(
950 "2b042",
951 "com.example.App",
952 ButtonId::Back,
953 Some(Action::Copy),
954 );
955 cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
956 assert!(
957 cfg.devices["2b042"].per_app_bindings.is_empty(),
958 "removing last override should prune the app entry"
959 );
960 }
961
962 #[test]
963 fn app_settings_default_omits_block() {
964 let cfg = Config::default();
965 let body = toml::to_string_pretty(&cfg).expect("serialize");
966 assert!(
967 !body.contains("app_settings"),
968 "default app_settings should be omitted: {body}"
969 );
970 }
971
972 #[test]
973 fn app_settings_launch_at_login_roundtrips() {
974 let mut cfg = Config::default();
975 cfg.app_settings.launch_at_login = true;
976 let parsed = write_and_read(&cfg);
977 assert!(parsed.app_settings.launch_at_login);
978 }
979
980 #[test]
981 fn cleared_selected_device_omits_field() {
982 let mut cfg = Config::default();
983 cfg.set_selected_device(Some("2b042".into()));
984 cfg.set_selected_device(None);
985 let body = toml::to_string_pretty(&cfg).expect("serialize");
986 assert!(
987 !body.contains("selected_device"),
988 "cleared selection should not appear: {body}"
989 );
990 }
991
992 #[test]
993 fn empty_device_block_is_skipped_in_output() {
994 let mut cfg = Config::default();
997 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
998 cfg.devices
999 .get_mut("2b042")
1000 .expect("entry")
1001 .bindings
1002 .clear();
1003 let body = toml::to_string_pretty(&cfg).expect("serialize");
1004 assert!(
1005 !body.contains("Back"),
1006 "cleared bindings should not appear: {body}"
1007 );
1008 }
1009
1010 #[test]
1011 fn migrates_v1_button_and_gesture_bindings() {
1012 let v1 = "\
1014schema_version = 1
1015
1016[devices.2b042.button_bindings]
1017Back = \"BrowserBack\"
1018
1019[devices.2b042.gesture_bindings]
1020Up = \"Copy\"
1021Click = \"Paste\"
1022";
1023 let dir = tempfile::tempdir().expect("tempdir");
1024 let path = dir.path().join("config.toml");
1025 fs::write(&path, v1).expect("write");
1026
1027 let cfg = Config::load_from_path(&path).expect("load v1");
1029 let bindings = cfg.bindings_for("2b042");
1030 assert_eq!(
1031 bindings.get(&ButtonId::Back),
1032 Some(&Binding::Single(Action::BrowserBack))
1033 );
1034 let mut gesture = BTreeMap::new();
1035 gesture.insert(GestureDirection::Up, Action::Copy);
1036 gesture.insert(GestureDirection::Click, Action::Paste);
1037 assert_eq!(
1038 bindings.get(&ButtonId::GestureButton),
1039 Some(&Binding::Gesture(gesture))
1040 );
1041
1042 let body = toml::to_string_pretty(&cfg).expect("serialize");
1045 assert!(body.contains("schema_version = 2"), "got: {body}");
1046 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1047 assert!(!body.contains("button_bindings"), "got: {body}");
1048 assert!(!body.contains("gesture_bindings"), "got: {body}");
1049 }
1050
1051 #[test]
1052 fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
1053 let v1 = "\
1058schema_version = 1
1059
1060[devices.2b042.button_bindings]
1061GestureButton = \"MissionControl\"
1062
1063[devices.2b042.gesture_bindings]
1064Up = \"Copy\"
1065Down = \"Paste\"
1066";
1067 let dir = tempfile::tempdir().expect("tempdir");
1068 let path = dir.path().join("config.toml");
1069 fs::write(&path, v1).expect("write");
1070
1071 let cfg = Config::load_from_path(&path).expect("load v1");
1072 let mut gesture = BTreeMap::new();
1073 gesture.insert(GestureDirection::Up, Action::Copy);
1074 gesture.insert(GestureDirection::Down, Action::Paste);
1075 assert_eq!(
1076 cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
1077 Some(&Binding::Gesture(gesture)),
1078 "gesture map must win over the legacy single GestureButton entry"
1079 );
1080 }
1081
1082 #[test]
1083 fn migration_drops_vestigial_lone_gesture_button_single() {
1084 let v1 = "\
1091schema_version = 1
1092
1093[devices.2b042.button_bindings]
1094GestureButton = \"MissionControl\"
1095Back = \"BrowserBack\"
1096";
1097 let dir = tempfile::tempdir().expect("tempdir");
1098 let path = dir.path().join("config.toml");
1099 fs::write(&path, v1).expect("write");
1100
1101 let bindings = Config::load_from_path(&path)
1102 .expect("load v1")
1103 .bindings_for("2b042");
1104 assert_eq!(
1106 bindings.get(&ButtonId::Back),
1107 Some(&Binding::Single(Action::BrowserBack))
1108 );
1109 assert_eq!(bindings.get(&ButtonId::GestureButton), None);
1112 }
1113
1114 #[test]
1115 fn rejects_newer_schema_version_but_accepts_v1() {
1116 let dir = tempfile::tempdir().expect("tempdir");
1119 let path = dir.path().join("config.toml");
1120 fs::write(&path, "schema_version = 99\n").expect("write");
1121 assert!(matches!(
1122 Config::load_from_path(&path).expect_err("v99 should fail"),
1123 ConfigError::UnsupportedSchemaVersion { found: 99, .. }
1124 ));
1125
1126 fs::write(&path, "schema_version = 1\n").expect("write");
1127 assert!(
1128 Config::load_from_path(&path).is_ok(),
1129 "v1 should still load"
1130 );
1131 }
1132
1133 #[test]
1134 fn set_gesture_direction_upgrades_single_to_gesture() {
1135 let mut cfg = Config::default();
1136 cfg.set_binding(
1138 "2b042",
1139 ButtonId::Back,
1140 Binding::Single(Action::BrowserBack),
1141 );
1142 cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
1143
1144 match cfg.bindings_for("2b042").get(&ButtonId::Back) {
1145 Some(Binding::Gesture(map)) => {
1146 assert_eq!(
1148 map.get(&GestureDirection::Click),
1149 Some(&Action::BrowserBack)
1150 );
1151 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1152 }
1153 other => panic!("expected Gesture after upgrade, got {other:?}"),
1154 }
1155 }
1156
1157 #[test]
1158 fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
1159 let mut cfg = Config::default();
1163 cfg.set_gesture_direction(
1164 "2b042",
1165 ButtonId::GestureButton,
1166 GestureDirection::Up,
1167 Action::Copy,
1168 );
1169
1170 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1171 Some(Binding::Gesture(map)) => {
1172 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1173 assert_eq!(
1174 map.get(&GestureDirection::Click),
1175 Some(&crate::binding::default_gesture_binding(
1176 GestureDirection::Click
1177 )),
1178 "a fresh gesture button must seed a Click from its default"
1179 );
1180 }
1181 other => panic!("expected Gesture, got {other:?}"),
1182 }
1183 }
1184
1185 #[test]
1186 fn gesture_owner_defaults_to_thumb_pad_yields_to_oshook_and_can_be_off() {
1187 let mut cfg = Config::default();
1188 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1190
1191 cfg.set_gesture_direction(
1193 "2b042",
1194 ButtonId::GestureButton,
1195 GestureDirection::Up,
1196 Action::MissionControl,
1197 );
1198 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1199
1200 cfg.set_binding(
1202 "2b042",
1203 ButtonId::Forward,
1204 Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
1205 );
1206 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
1207
1208 let mut off = Config::default();
1210 off.disable_gestures("2b042");
1211 assert_eq!(off.gesture_owner("2b042"), None);
1212 }
1213
1214 #[test]
1215 fn set_gesture_owner_records_owner_without_destroying_other_maps() {
1216 let mut cfg = Config::default();
1217 cfg.set_gesture_direction(
1219 "2b042",
1220 ButtonId::GestureButton,
1221 GestureDirection::Up,
1222 Action::Copy,
1223 );
1224 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1225
1226 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
1229 cfg.set_gesture_owner("2b042", ButtonId::Back);
1230 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
1231
1232 let bindings = cfg.bindings_for("2b042");
1233 match bindings.get(&ButtonId::Back) {
1236 Some(Binding::Gesture(map)) => {
1237 assert_eq!(
1238 map.get(&GestureDirection::Click),
1239 Some(&Action::BrowserBack)
1240 );
1241 assert_eq!(
1242 map.get(&GestureDirection::Up),
1243 Some(&default_gesture_binding(GestureDirection::Up)),
1244 "a promoted button gets full default arms"
1245 );
1246 }
1247 other => panic!("expected Back to be a gesture binding, got {other:?}"),
1248 }
1249 match bindings.get(&ButtonId::GestureButton) {
1251 Some(Binding::Gesture(map)) => {
1252 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1253 }
1254 other => panic!("expected the thumb pad map preserved, got {other:?}"),
1255 }
1256
1257 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1260 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1261 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1262 Some(Binding::Gesture(map)) => {
1263 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1264 }
1265 other => panic!("expected preserved gesture map, got {other:?}"),
1266 }
1267 }
1268
1269 #[test]
1270 fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
1271 let mut cfg = Config::default();
1272 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1274 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1275 Some(Binding::Gesture(map)) => {
1276 for dir in GestureDirection::ALL {
1277 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1278 }
1279 }
1280 other => panic!("expected full default gesture map, got {other:?}"),
1281 }
1282
1283 cfg.set_gesture_owner("2b042", ButtonId::Forward);
1287 match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
1288 Some(Binding::Gesture(map)) => {
1289 assert_eq!(
1290 map.get(&GestureDirection::Click),
1291 Some(&default_binding(ButtonId::Forward))
1292 );
1293 for dir in [
1294 GestureDirection::Up,
1295 GestureDirection::Down,
1296 GestureDirection::Left,
1297 GestureDirection::Right,
1298 ] {
1299 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1300 }
1301 }
1302 other => panic!("expected full gesture map for Forward, got {other:?}"),
1303 }
1304 }
1305
1306 #[test]
1307 fn disable_gestures_turns_off_without_destroying_maps() {
1308 let mut cfg = Config::default();
1309 cfg.set_gesture_direction(
1310 "2b042",
1311 ButtonId::GestureButton,
1312 GestureDirection::Up,
1313 Action::Copy,
1314 );
1315 cfg.disable_gestures("2b042");
1316 assert_eq!(cfg.gesture_owner("2b042"), None);
1319 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1320 Some(Binding::Gesture(map)) => {
1321 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1322 }
1323 other => panic!("expected the gesture map preserved while off, got {other:?}"),
1324 }
1325 }
1326
1327 #[test]
1328 fn gesture_owner_field_roundtrips_as_a_scalar() {
1329 let mut cfg = Config::default();
1330 cfg.set_gesture_owner("2b042", ButtonId::Back); cfg.disable_gestures("4082d"); let parsed = write_and_read(&cfg);
1334 assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
1335 assert_eq!(parsed.gesture_owner("4082d"), None);
1336
1337 let body = toml::to_string_pretty(&cfg).expect("serialize");
1340 assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
1341 assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
1342 }
1343
1344 #[test]
1345 fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
1346 let toml = "\
1350schema_version = 2
1351
1352[devices.2b042]
1353gesture_owner = \"bogus\"
1354
1355[devices.2b042.bindings]
1356Back = \"Copy\"
1357";
1358 let dir = tempfile::tempdir().expect("tempdir");
1359 let path = dir.path().join("config.toml");
1360 fs::write(&path, toml).expect("write");
1361
1362 let cfg =
1363 Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
1364 assert_eq!(
1366 cfg.bindings_for("2b042").get(&ButtonId::Back),
1367 Some(&Binding::Single(Action::Copy))
1368 );
1369 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1371 }
1372}