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 = "default_true")]
102 pub auto_download_assets: bool,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub language: Option<String>,
110 #[serde(default = "default_thumbwheel_sensitivity")]
117 pub thumbwheel_sensitivity: i32,
118}
119
120pub const DEFAULT_THUMBWHEEL_SENSITIVITY: i32 = 14;
124pub const MIN_THUMBWHEEL_SENSITIVITY: i32 = 1;
126pub const MAX_THUMBWHEEL_SENSITIVITY: i32 = 100;
128
129impl AppSettings {
130 #[must_use]
133 pub fn is_default(&self) -> bool {
134 self == &Self::default()
135 }
136}
137
138impl Default for AppSettings {
139 fn default() -> Self {
140 Self {
141 launch_at_login: false,
142 check_for_updates: false,
143 update_prompt_seen: false,
144 show_in_menu_bar: true,
145 auto_download_assets: true,
146 language: None,
147 thumbwheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY,
148 }
149 }
150}
151
152fn default_true() -> bool {
155 true
156}
157
158const fn default_thumbwheel_sensitivity() -> i32 {
161 DEFAULT_THUMBWHEEL_SENSITIVITY
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct Lighting {
172 #[serde(default = "default_lighting_enabled")]
173 pub enabled: bool,
174 #[serde(default = "default_lighting_color")]
176 pub color: String,
177 #[serde(
179 default = "default_lighting_brightness",
180 deserialize_with = "deserialize_brightness"
181 )]
182 pub brightness: u8,
183}
184
185impl Default for Lighting {
186 fn default() -> Self {
187 Self {
188 enabled: default_lighting_enabled(),
189 color: default_lighting_color(),
190 brightness: default_lighting_brightness(),
191 }
192 }
193}
194
195fn default_lighting_enabled() -> bool {
196 true
197}
198
199fn default_lighting_color() -> String {
200 "ffffff".to_string()
201}
202
203fn default_lighting_brightness() -> u8 {
204 100
205}
206
207fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
211where
212 D: serde::Deserializer<'de>,
213{
214 Ok(u8::deserialize(deserializer)?.min(100))
215}
216
217#[derive(Clone, Copy, Debug, PartialEq, Eq)]
225pub enum GestureOwner {
226 Off,
228 Button(ButtonId),
230}
231
232impl Serialize for GestureOwner {
233 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
234 match self {
235 GestureOwner::Off => serializer.serialize_str("Off"),
238 GestureOwner::Button(id) => id.serialize(serializer),
239 }
240 }
241}
242
243fn deserialize_gesture_owner<'de, D>(deserializer: D) -> Result<Option<GestureOwner>, D::Error>
250where
251 D: serde::Deserializer<'de>,
252{
253 let s = String::deserialize(deserializer)?;
254 if s == "Off" {
255 return Ok(Some(GestureOwner::Off));
256 }
257 let button = ButtonId::deserialize(
260 serde::de::value::StrDeserializer::<serde::de::value::Error>::new(&s),
261 )
262 .ok();
263 Ok(button.map(GestureOwner::Button))
264}
265
266#[derive(Debug, Clone, Default, Serialize, Deserialize)]
274#[serde(from = "RawDeviceConfig")]
275pub struct DeviceConfig {
276 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub gesture_owner: Option<GestureOwner>,
282 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
286 pub bindings: BTreeMap<ButtonId, Binding>,
287 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
294 pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
295 #[serde(default, skip_serializing_if = "Vec::is_empty")]
300 pub dpi_presets: Vec<u32>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub lighting: Option<Lighting>,
305}
306
307#[derive(Deserialize)]
312struct RawDeviceConfig {
313 #[serde(default, deserialize_with = "deserialize_gesture_owner")]
318 gesture_owner: Option<GestureOwner>,
319 #[serde(default)]
321 bindings: BTreeMap<ButtonId, Binding>,
322 #[serde(default)]
324 button_bindings: BTreeMap<ButtonId, Action>,
325 #[serde(default)]
327 gesture_bindings: BTreeMap<GestureDirection, Action>,
328 #[serde(default)]
329 per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
330 #[serde(default)]
331 dpi_presets: Vec<u32>,
332 #[serde(default)]
333 lighting: Option<Lighting>,
334}
335
336impl From<RawDeviceConfig> for DeviceConfig {
337 fn from(raw: RawDeviceConfig) -> Self {
338 let mut bindings = raw.bindings; if !raw.gesture_bindings.is_empty() {
346 bindings
347 .entry(ButtonId::GestureButton)
348 .or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
349 }
350 for (button, action) in raw.button_bindings {
351 if button == ButtonId::GestureButton {
362 continue;
363 }
364 bindings.entry(button).or_insert(Binding::Single(action));
365 }
366
367 DeviceConfig {
368 gesture_owner: raw.gesture_owner,
369 bindings,
370 per_app_bindings: raw.per_app_bindings,
371 dpi_presets: raw.dpi_presets,
372 lighting: raw.lighting,
373 }
374 }
375}
376
377#[derive(Debug, Error)]
378pub enum ConfigError {
379 #[error("could not resolve config path")]
380 Path(#[from] PathsError),
381 #[error("could not read config at {path}")]
382 Read {
383 path: PathBuf,
384 #[source]
385 source: io::Error,
386 },
387 #[error("could not parse config at {path}")]
388 Parse {
389 path: PathBuf,
390 #[source]
391 source: toml::de::Error,
392 },
393 #[error("could not write config at {path}")]
394 Write {
395 path: PathBuf,
396 #[source]
397 source: io::Error,
398 },
399 #[error("could not serialize config")]
400 Serialize(#[from] toml::ser::Error),
401 #[error("config at {path} has unsupported schema_version {found}")]
402 UnsupportedSchemaVersion { path: PathBuf, found: u32 },
403}
404
405#[allow(
406 clippy::result_large_err,
407 reason = "Config I/O keeps rich parse/write context and is not a hot path"
408)]
409impl Config {
410 pub fn load_or_default() -> Result<Self, ConfigError> {
413 Self::load_from_path(&paths::config_path()?)
414 }
415
416 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
419 match fs::read_to_string(path) {
420 Ok(text) => {
421 let mut config: Self =
422 toml::from_str(&text).map_err(|source| ConfigError::Parse {
423 path: path.to_path_buf(),
424 source,
425 })?;
426 if config.schema_version > SCHEMA_VERSION {
432 return Err(ConfigError::UnsupportedSchemaVersion {
433 path: path.to_path_buf(),
434 found: config.schema_version,
435 });
436 }
437 config.schema_version = SCHEMA_VERSION;
441 Ok(config)
442 }
443 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
444 Err(source) => Err(ConfigError::Read {
445 path: path.to_path_buf(),
446 source,
447 }),
448 }
449 }
450
451 pub fn save_atomic(&self) -> Result<(), ConfigError> {
455 self.save_to_path(&paths::config_path()?)
456 }
457
458 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
460 if let Some(parent) = path.parent() {
461 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
462 path: path.to_path_buf(),
463 source,
464 })?;
465 }
466 let body = toml::to_string_pretty(self)?;
467 write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
468 path: path.to_path_buf(),
469 source,
470 })
471 }
472
473 #[must_use]
476 pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
477 self.devices
478 .get(device_key)
479 .map(|d| d.bindings.clone())
480 .unwrap_or_default()
481 }
482
483 pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
488 self.devices
489 .entry(device_key.to_string())
490 .or_default()
491 .bindings
492 .insert(button, binding);
493 }
494
495 #[must_use]
500 pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
501 match self
502 .devices
503 .get(device_key)
504 .and_then(|d| d.bindings.get(&ButtonId::GestureButton))
505 {
506 Some(Binding::Gesture(map)) => map.clone(),
507 _ => BTreeMap::new(),
508 }
509 }
510
511 pub fn set_gesture_direction(
521 &mut self,
522 device_key: &str,
523 button: ButtonId,
524 direction: GestureDirection,
525 action: Action,
526 ) {
527 if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
528 map.insert(direction, action);
529 }
530 }
531
532 fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
540 let entry = self
541 .devices
542 .entry(device_key.to_string())
543 .or_default()
544 .bindings
545 .entry(button)
546 .or_insert_with(|| default_binding_for(button));
547 entry.upgrade_to_gesture();
548 entry
549 }
550
551 #[must_use]
560 pub fn gesture_owner(&self, device_key: &str) -> Option<ButtonId> {
561 let Some(device) = self.devices.get(device_key) else {
562 return Some(ButtonId::GestureButton);
564 };
565 match device.gesture_owner {
566 Some(GestureOwner::Off) => None,
567 Some(GestureOwner::Button(id)) => Some(id),
568 None => Self::infer_gesture_owner(&device.bindings),
569 }
570 }
571
572 fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
577 if let Some((id, _)) = bindings
579 .iter()
580 .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
581 {
582 return Some(*id);
583 }
584 if matches!(
586 bindings.get(&ButtonId::GestureButton),
587 Some(Binding::Single(_))
588 ) {
589 return None;
590 }
591 Some(ButtonId::GestureButton)
593 }
594
595 pub fn set_gesture_owner(&mut self, device_key: &str, button: ButtonId) {
607 self.devices
608 .entry(device_key.to_string())
609 .or_default()
610 .gesture_owner = Some(GestureOwner::Button(button));
611 self.ensure_gesture_binding(device_key, button)
612 .fill_gesture_defaults();
613 }
614
615 pub fn disable_gestures(&mut self, device_key: &str) {
619 self.devices
620 .entry(device_key.to_string())
621 .or_default()
622 .gesture_owner = Some(GestureOwner::Off);
623 }
624
625 #[must_use]
633 pub fn effective_bindings(
634 &self,
635 device_key: &str,
636 bundle_id: Option<&str>,
637 ) -> BTreeMap<ButtonId, Binding> {
638 let Some(device) = self.devices.get(device_key) else {
639 return BTreeMap::new();
640 };
641 let mut out = device.bindings.clone();
642 if let Some(bid) = bundle_id
643 && let Some(overlay) = device.per_app_bindings.get(bid)
644 {
645 for (k, v) in overlay {
646 out.insert(*k, Binding::Single(v.clone()));
647 }
648 }
649 out
650 }
651
652 pub fn set_per_app_binding(
656 &mut self,
657 device_key: &str,
658 bundle_id: &str,
659 button: ButtonId,
660 action: Option<Action>,
661 ) {
662 let entry = self
663 .devices
664 .entry(device_key.to_string())
665 .or_default()
666 .per_app_bindings
667 .entry(bundle_id.to_string())
668 .or_default();
669 match action {
670 Some(a) => {
671 entry.insert(button, a);
672 }
673 None => {
674 entry.remove(&button);
675 }
676 }
677 if let Some(d) = self.devices.get_mut(device_key) {
678 d.per_app_bindings.retain(|_, m| !m.is_empty());
679 }
680 }
681
682 #[must_use]
684 pub fn selected_device(&self) -> Option<&str> {
685 self.selected_device.as_deref()
686 }
687
688 pub fn set_selected_device(&mut self, key: Option<String>) {
691 self.selected_device = key;
692 }
693
694 #[must_use]
697 pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
698 self.devices
699 .get(device_key)
700 .map(|d| d.dpi_presets.clone())
701 .unwrap_or_default()
702 }
703
704 pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
708 self.devices
709 .entry(device_key.to_string())
710 .or_default()
711 .dpi_presets = presets;
712 }
713
714 #[must_use]
716 pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
717 self.devices
718 .get(device_key)
719 .and_then(|d| d.lighting.clone())
720 }
721
722 pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
724 self.devices
725 .entry(device_key.to_string())
726 .or_default()
727 .lighting = Some(lighting);
728 }
729}
730
731fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
732 let tmp = path.with_extension("toml.tmp");
733 {
734 #[cfg(unix)]
735 {
736 use std::os::unix::fs::OpenOptionsExt;
737 let mut f = fs::OpenOptions::new()
738 .write(true)
739 .create(true)
740 .truncate(true)
741 .mode(0o600)
742 .open(&tmp)?;
743 io::Write::write_all(&mut f, bytes)?;
744 f.sync_all()?;
745 }
746 #[cfg(not(unix))]
747 {
748 let mut f = fs::OpenOptions::new()
749 .write(true)
750 .create(true)
751 .truncate(true)
752 .open(&tmp)?;
753 io::Write::write_all(&mut f, bytes)?;
754 f.sync_all()?;
755 }
756 }
757 fs::rename(&tmp, path)
758}
759
760#[cfg(test)]
761#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
762mod tests {
763 use super::*;
764 use crate::binding::{default_binding, default_gesture_binding};
765
766 fn write_and_read(config: &Config) -> Config {
767 let dir = tempfile::tempdir().expect("tempdir");
768 let path = dir.path().join("config.toml");
769 config.save_to_path(&path).expect("save");
770 Config::load_from_path(&path).expect("load")
771 }
772
773 #[test]
774 fn missing_file_yields_default() {
775 let dir = tempfile::tempdir().expect("tempdir");
776 let path = dir.path().join("nonexistent.toml");
777 let cfg = Config::load_from_path(&path).expect("load");
778 assert_eq!(cfg.schema_version, SCHEMA_VERSION);
779 assert!(cfg.devices.is_empty());
780 }
781
782 #[test]
783 fn lighting_roundtrips_per_device() {
784 let mut cfg = Config::default();
785 cfg.set_lighting(
786 "g513",
787 Lighting {
788 enabled: true,
789 color: "00aabb".to_string(),
790 brightness: 75,
791 },
792 );
793 let restored = write_and_read(&cfg);
794 assert_eq!(
795 restored.lighting("g513"),
796 Some(Lighting {
797 enabled: true,
798 color: "00aabb".to_string(),
799 brightness: 75,
800 })
801 );
802 assert_eq!(restored.lighting("absent"), None);
803 }
804
805 #[test]
806 fn bindings_roundtrip_per_device() {
807 let mut cfg = Config::default();
808 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
809 cfg.set_binding(
810 "2b042",
811 ButtonId::DpiToggle,
812 Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
813 modifiers: crate::binding::KeyCombo::MOD_CMD,
814 key_code: 0x23, display: "⌘P".into(),
816 })),
817 );
818 cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
819
820 let parsed = write_and_read(&cfg);
821
822 let a = parsed.bindings_for("2b042");
824 assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
825 assert_eq!(
826 a.get(&ButtonId::DpiToggle),
827 Some(&Binding::Single(Action::CustomShortcut(
828 crate::binding::KeyCombo {
829 modifiers: crate::binding::KeyCombo::MOD_CMD,
830 key_code: 0x23,
831 display: "⌘P".into(),
832 }
833 )))
834 );
835
836 let b = parsed.bindings_for("4082d");
837 assert_eq!(
838 b.get(&ButtonId::Back),
839 Some(&Binding::Single(Action::Paste))
840 );
841 assert_eq!(b.len(), 1, "device b should only see its own bindings");
842
843 assert!(parsed.bindings_for("deadbeef").is_empty());
845 }
846
847 #[test]
848 fn human_readable_toml_layout() {
849 let mut cfg = Config::default();
850 cfg.set_binding(
851 "2b042",
852 ButtonId::Back,
853 Binding::Single(Action::BrowserBack),
854 );
855 let body = toml::to_string_pretty(&cfg).expect("serialize");
856
857 assert!(body.contains("schema_version = 2"), "got: {body}");
861 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
862 assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
865 }
866
867 #[test]
868 fn dpi_presets_roundtrip_per_device() {
869 let mut cfg = Config::default();
870 cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
871 cfg.set_dpi_presets("4082d", vec![400, 1600]);
872
873 let parsed = write_and_read(&cfg);
874
875 assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
876 assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
877 assert!(parsed.dpi_presets("unknown").is_empty());
878 }
879
880 #[test]
881 fn empty_dpi_presets_skip_serialization() {
882 let mut cfg = Config::default();
883 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
885 cfg.set_dpi_presets("2b042", vec![800]);
886 cfg.set_dpi_presets("2b042", vec![]); let body = toml::to_string_pretty(&cfg).expect("serialize");
889 assert!(
890 !body.contains("dpi_presets"),
891 "empty dpi_presets should be omitted: {body}"
892 );
893 }
894
895 #[test]
896 fn selected_device_roundtrips() {
897 let mut cfg = Config::default();
898 assert_eq!(cfg.selected_device(), None);
899 cfg.set_selected_device(Some("2b042".into()));
900 let parsed = write_and_read(&cfg);
901 assert_eq!(parsed.selected_device(), Some("2b042"));
902 }
903
904 #[test]
905 fn per_app_overlay_takes_precedence() {
906 let mut cfg = Config::default();
907 cfg.set_binding(
908 "2b042",
909 ButtonId::Back,
910 Binding::Single(Action::BrowserBack),
911 );
912 cfg.set_binding(
913 "2b042",
914 ButtonId::Forward,
915 Binding::Single(Action::BrowserForward),
916 );
917 cfg.set_per_app_binding(
918 "2b042",
919 "com.microsoft.VSCode",
920 ButtonId::Back,
921 Some(Action::Undo),
922 );
923
924 let global = cfg.effective_bindings("2b042", None);
926 assert_eq!(
927 global.get(&ButtonId::Back),
928 Some(&Binding::Single(Action::BrowserBack))
929 );
930 assert_eq!(
931 global.get(&ButtonId::Forward),
932 Some(&Binding::Single(Action::BrowserForward))
933 );
934
935 let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
937 assert_eq!(
938 vscode.get(&ButtonId::Back),
939 Some(&Binding::Single(Action::Undo))
940 );
941 assert_eq!(
942 vscode.get(&ButtonId::Forward),
943 Some(&Binding::Single(Action::BrowserForward))
944 );
945
946 let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
948 assert_eq!(
949 other.get(&ButtonId::Back),
950 Some(&Binding::Single(Action::BrowserBack))
951 );
952 }
953
954 #[test]
955 fn per_app_binding_removal_prunes_empty_app() {
956 let mut cfg = Config::default();
957 cfg.set_per_app_binding(
958 "2b042",
959 "com.example.App",
960 ButtonId::Back,
961 Some(Action::Copy),
962 );
963 cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
964 assert!(
965 cfg.devices["2b042"].per_app_bindings.is_empty(),
966 "removing last override should prune the app entry"
967 );
968 }
969
970 #[test]
971 fn app_settings_default_omits_block() {
972 let cfg = Config::default();
973 let body = toml::to_string_pretty(&cfg).expect("serialize");
974 assert!(
975 !body.contains("app_settings"),
976 "default app_settings should be omitted: {body}"
977 );
978 }
979
980 #[test]
981 fn app_settings_launch_at_login_roundtrips() {
982 let mut cfg = Config::default();
983 cfg.app_settings.launch_at_login = true;
984 let parsed = write_and_read(&cfg);
985 assert!(parsed.app_settings.launch_at_login);
986 }
987
988 #[test]
989 fn cleared_selected_device_omits_field() {
990 let mut cfg = Config::default();
991 cfg.set_selected_device(Some("2b042".into()));
992 cfg.set_selected_device(None);
993 let body = toml::to_string_pretty(&cfg).expect("serialize");
994 assert!(
995 !body.contains("selected_device"),
996 "cleared selection should not appear: {body}"
997 );
998 }
999
1000 #[test]
1001 fn empty_device_block_is_skipped_in_output() {
1002 let mut cfg = Config::default();
1005 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1006 cfg.devices
1007 .get_mut("2b042")
1008 .expect("entry")
1009 .bindings
1010 .clear();
1011 let body = toml::to_string_pretty(&cfg).expect("serialize");
1012 assert!(
1013 !body.contains("Back"),
1014 "cleared bindings should not appear: {body}"
1015 );
1016 }
1017
1018 #[test]
1019 fn migrates_v1_button_and_gesture_bindings() {
1020 let v1 = "\
1022schema_version = 1
1023
1024[devices.2b042.button_bindings]
1025Back = \"BrowserBack\"
1026
1027[devices.2b042.gesture_bindings]
1028Up = \"Copy\"
1029Click = \"Paste\"
1030";
1031 let dir = tempfile::tempdir().expect("tempdir");
1032 let path = dir.path().join("config.toml");
1033 fs::write(&path, v1).expect("write");
1034
1035 let cfg = Config::load_from_path(&path).expect("load v1");
1037 let bindings = cfg.bindings_for("2b042");
1038 assert_eq!(
1039 bindings.get(&ButtonId::Back),
1040 Some(&Binding::Single(Action::BrowserBack))
1041 );
1042 let mut gesture = BTreeMap::new();
1043 gesture.insert(GestureDirection::Up, Action::Copy);
1044 gesture.insert(GestureDirection::Click, Action::Paste);
1045 assert_eq!(
1046 bindings.get(&ButtonId::GestureButton),
1047 Some(&Binding::Gesture(gesture))
1048 );
1049
1050 let body = toml::to_string_pretty(&cfg).expect("serialize");
1053 assert!(body.contains("schema_version = 2"), "got: {body}");
1054 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1055 assert!(!body.contains("button_bindings"), "got: {body}");
1056 assert!(!body.contains("gesture_bindings"), "got: {body}");
1057 }
1058
1059 #[test]
1060 fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
1061 let v1 = "\
1066schema_version = 1
1067
1068[devices.2b042.button_bindings]
1069GestureButton = \"MissionControl\"
1070
1071[devices.2b042.gesture_bindings]
1072Up = \"Copy\"
1073Down = \"Paste\"
1074";
1075 let dir = tempfile::tempdir().expect("tempdir");
1076 let path = dir.path().join("config.toml");
1077 fs::write(&path, v1).expect("write");
1078
1079 let cfg = Config::load_from_path(&path).expect("load v1");
1080 let mut gesture = BTreeMap::new();
1081 gesture.insert(GestureDirection::Up, Action::Copy);
1082 gesture.insert(GestureDirection::Down, Action::Paste);
1083 assert_eq!(
1084 cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
1085 Some(&Binding::Gesture(gesture)),
1086 "gesture map must win over the legacy single GestureButton entry"
1087 );
1088 }
1089
1090 #[test]
1091 fn migration_drops_vestigial_lone_gesture_button_single() {
1092 let v1 = "\
1099schema_version = 1
1100
1101[devices.2b042.button_bindings]
1102GestureButton = \"MissionControl\"
1103Back = \"BrowserBack\"
1104";
1105 let dir = tempfile::tempdir().expect("tempdir");
1106 let path = dir.path().join("config.toml");
1107 fs::write(&path, v1).expect("write");
1108
1109 let bindings = Config::load_from_path(&path)
1110 .expect("load v1")
1111 .bindings_for("2b042");
1112 assert_eq!(
1114 bindings.get(&ButtonId::Back),
1115 Some(&Binding::Single(Action::BrowserBack))
1116 );
1117 assert_eq!(bindings.get(&ButtonId::GestureButton), None);
1120 }
1121
1122 #[test]
1123 fn rejects_newer_schema_version_but_accepts_v1() {
1124 let dir = tempfile::tempdir().expect("tempdir");
1127 let path = dir.path().join("config.toml");
1128 fs::write(&path, "schema_version = 99\n").expect("write");
1129 assert!(matches!(
1130 Config::load_from_path(&path).expect_err("v99 should fail"),
1131 ConfigError::UnsupportedSchemaVersion { found: 99, .. }
1132 ));
1133
1134 fs::write(&path, "schema_version = 1\n").expect("write");
1135 assert!(
1136 Config::load_from_path(&path).is_ok(),
1137 "v1 should still load"
1138 );
1139 }
1140
1141 #[test]
1142 fn set_gesture_direction_upgrades_single_to_gesture() {
1143 let mut cfg = Config::default();
1144 cfg.set_binding(
1146 "2b042",
1147 ButtonId::Back,
1148 Binding::Single(Action::BrowserBack),
1149 );
1150 cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
1151
1152 match cfg.bindings_for("2b042").get(&ButtonId::Back) {
1153 Some(Binding::Gesture(map)) => {
1154 assert_eq!(
1156 map.get(&GestureDirection::Click),
1157 Some(&Action::BrowserBack)
1158 );
1159 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1160 }
1161 other => panic!("expected Gesture after upgrade, got {other:?}"),
1162 }
1163 }
1164
1165 #[test]
1166 fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
1167 let mut cfg = Config::default();
1171 cfg.set_gesture_direction(
1172 "2b042",
1173 ButtonId::GestureButton,
1174 GestureDirection::Up,
1175 Action::Copy,
1176 );
1177
1178 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1179 Some(Binding::Gesture(map)) => {
1180 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1181 assert_eq!(
1182 map.get(&GestureDirection::Click),
1183 Some(&crate::binding::default_gesture_binding(
1184 GestureDirection::Click
1185 )),
1186 "a fresh gesture button must seed a Click from its default"
1187 );
1188 }
1189 other => panic!("expected Gesture, got {other:?}"),
1190 }
1191 }
1192
1193 #[test]
1194 fn gesture_owner_defaults_to_thumb_pad_yields_to_oshook_and_can_be_off() {
1195 let mut cfg = Config::default();
1196 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1198
1199 cfg.set_gesture_direction(
1201 "2b042",
1202 ButtonId::GestureButton,
1203 GestureDirection::Up,
1204 Action::MissionControl,
1205 );
1206 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1207
1208 cfg.set_binding(
1210 "2b042",
1211 ButtonId::Forward,
1212 Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
1213 );
1214 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
1215
1216 let mut off = Config::default();
1218 off.disable_gestures("2b042");
1219 assert_eq!(off.gesture_owner("2b042"), None);
1220 }
1221
1222 #[test]
1223 fn set_gesture_owner_records_owner_without_destroying_other_maps() {
1224 let mut cfg = Config::default();
1225 cfg.set_gesture_direction(
1227 "2b042",
1228 ButtonId::GestureButton,
1229 GestureDirection::Up,
1230 Action::Copy,
1231 );
1232 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1233
1234 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
1237 cfg.set_gesture_owner("2b042", ButtonId::Back);
1238 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
1239
1240 let bindings = cfg.bindings_for("2b042");
1241 match bindings.get(&ButtonId::Back) {
1244 Some(Binding::Gesture(map)) => {
1245 assert_eq!(
1246 map.get(&GestureDirection::Click),
1247 Some(&Action::BrowserBack)
1248 );
1249 assert_eq!(
1250 map.get(&GestureDirection::Up),
1251 Some(&default_gesture_binding(GestureDirection::Up)),
1252 "a promoted button gets full default arms"
1253 );
1254 }
1255 other => panic!("expected Back to be a gesture binding, got {other:?}"),
1256 }
1257 match bindings.get(&ButtonId::GestureButton) {
1259 Some(Binding::Gesture(map)) => {
1260 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1261 }
1262 other => panic!("expected the thumb pad map preserved, got {other:?}"),
1263 }
1264
1265 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1268 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1269 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1270 Some(Binding::Gesture(map)) => {
1271 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1272 }
1273 other => panic!("expected preserved gesture map, got {other:?}"),
1274 }
1275 }
1276
1277 #[test]
1278 fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
1279 let mut cfg = Config::default();
1280 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1282 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1283 Some(Binding::Gesture(map)) => {
1284 for dir in GestureDirection::ALL {
1285 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1286 }
1287 }
1288 other => panic!("expected full default gesture map, got {other:?}"),
1289 }
1290
1291 cfg.set_gesture_owner("2b042", ButtonId::Forward);
1295 match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
1296 Some(Binding::Gesture(map)) => {
1297 assert_eq!(
1298 map.get(&GestureDirection::Click),
1299 Some(&default_binding(ButtonId::Forward))
1300 );
1301 for dir in [
1302 GestureDirection::Up,
1303 GestureDirection::Down,
1304 GestureDirection::Left,
1305 GestureDirection::Right,
1306 ] {
1307 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1308 }
1309 }
1310 other => panic!("expected full gesture map for Forward, got {other:?}"),
1311 }
1312 }
1313
1314 #[test]
1315 fn disable_gestures_turns_off_without_destroying_maps() {
1316 let mut cfg = Config::default();
1317 cfg.set_gesture_direction(
1318 "2b042",
1319 ButtonId::GestureButton,
1320 GestureDirection::Up,
1321 Action::Copy,
1322 );
1323 cfg.disable_gestures("2b042");
1324 assert_eq!(cfg.gesture_owner("2b042"), None);
1327 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1328 Some(Binding::Gesture(map)) => {
1329 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1330 }
1331 other => panic!("expected the gesture map preserved while off, got {other:?}"),
1332 }
1333 }
1334
1335 #[test]
1336 fn gesture_owner_field_roundtrips_as_a_scalar() {
1337 let mut cfg = Config::default();
1338 cfg.set_gesture_owner("2b042", ButtonId::Back); cfg.disable_gestures("4082d"); let parsed = write_and_read(&cfg);
1342 assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
1343 assert_eq!(parsed.gesture_owner("4082d"), None);
1344
1345 let body = toml::to_string_pretty(&cfg).expect("serialize");
1348 assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
1349 assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
1350 }
1351
1352 #[test]
1353 fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
1354 let toml = "\
1358schema_version = 2
1359
1360[devices.2b042]
1361gesture_owner = \"bogus\"
1362
1363[devices.2b042.bindings]
1364Back = \"Copy\"
1365";
1366 let dir = tempfile::tempdir().expect("tempdir");
1367 let path = dir.path().join("config.toml");
1368 fs::write(&path, toml).expect("write");
1369
1370 let cfg =
1371 Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
1372 assert_eq!(
1374 cfg.bindings_for("2b042").get(&ButtonId::Back),
1375 Some(&Binding::Single(Action::Copy))
1376 );
1377 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1379 }
1380}