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)]
159pub struct Lighting {
160 #[serde(default = "default_lighting_enabled")]
161 pub enabled: bool,
162 #[serde(default = "default_lighting_color")]
164 pub color: String,
165 #[serde(
167 default = "default_lighting_brightness",
168 deserialize_with = "deserialize_brightness"
169 )]
170 pub brightness: u8,
171}
172
173impl Default for Lighting {
174 fn default() -> Self {
175 Self {
176 enabled: default_lighting_enabled(),
177 color: default_lighting_color(),
178 brightness: default_lighting_brightness(),
179 }
180 }
181}
182
183fn default_lighting_enabled() -> bool {
184 true
185}
186
187fn default_lighting_color() -> String {
188 "ffffff".to_string()
189}
190
191fn default_lighting_brightness() -> u8 {
192 100
193}
194
195fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
199where
200 D: serde::Deserializer<'de>,
201{
202 Ok(u8::deserialize(deserializer)?.min(100))
203}
204
205#[derive(Clone, Copy, Debug, PartialEq, Eq)]
213pub enum GestureOwner {
214 Off,
216 Button(ButtonId),
218}
219
220impl Serialize for GestureOwner {
221 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
222 match self {
223 GestureOwner::Off => serializer.serialize_str("Off"),
226 GestureOwner::Button(id) => id.serialize(serializer),
227 }
228 }
229}
230
231fn deserialize_gesture_owner<'de, D>(deserializer: D) -> Result<Option<GestureOwner>, D::Error>
238where
239 D: serde::Deserializer<'de>,
240{
241 let s = String::deserialize(deserializer)?;
242 if s == "Off" {
243 return Ok(Some(GestureOwner::Off));
244 }
245 let button = ButtonId::deserialize(
248 serde::de::value::StrDeserializer::<serde::de::value::Error>::new(&s),
249 )
250 .ok();
251 Ok(button.map(GestureOwner::Button))
252}
253
254#[derive(Debug, Clone, Default, Serialize, Deserialize)]
262#[serde(from = "RawDeviceConfig")]
263pub struct DeviceConfig {
264 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub gesture_owner: Option<GestureOwner>,
270 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
274 pub bindings: BTreeMap<ButtonId, Binding>,
275 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
282 pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
283 #[serde(default, skip_serializing_if = "Vec::is_empty")]
288 pub dpi_presets: Vec<u32>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub lighting: Option<Lighting>,
293}
294
295#[derive(Deserialize)]
300struct RawDeviceConfig {
301 #[serde(default, deserialize_with = "deserialize_gesture_owner")]
306 gesture_owner: Option<GestureOwner>,
307 #[serde(default)]
309 bindings: BTreeMap<ButtonId, Binding>,
310 #[serde(default)]
312 button_bindings: BTreeMap<ButtonId, Action>,
313 #[serde(default)]
315 gesture_bindings: BTreeMap<GestureDirection, Action>,
316 #[serde(default)]
317 per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
318 #[serde(default)]
319 dpi_presets: Vec<u32>,
320 #[serde(default)]
321 lighting: Option<Lighting>,
322}
323
324impl From<RawDeviceConfig> for DeviceConfig {
325 fn from(raw: RawDeviceConfig) -> Self {
326 let mut bindings = raw.bindings; if !raw.gesture_bindings.is_empty() {
334 bindings
335 .entry(ButtonId::GestureButton)
336 .or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
337 }
338 for (button, action) in raw.button_bindings {
339 if button == ButtonId::GestureButton {
350 continue;
351 }
352 bindings.entry(button).or_insert(Binding::Single(action));
353 }
354
355 DeviceConfig {
356 gesture_owner: raw.gesture_owner,
357 bindings,
358 per_app_bindings: raw.per_app_bindings,
359 dpi_presets: raw.dpi_presets,
360 lighting: raw.lighting,
361 }
362 }
363}
364
365#[derive(Debug, Error)]
366pub enum ConfigError {
367 #[error("could not resolve config path")]
368 Path(#[from] PathsError),
369 #[error("could not read config at {path}")]
370 Read {
371 path: PathBuf,
372 #[source]
373 source: io::Error,
374 },
375 #[error("could not parse config at {path}")]
376 Parse {
377 path: PathBuf,
378 #[source]
379 source: toml::de::Error,
380 },
381 #[error("could not write config at {path}")]
382 Write {
383 path: PathBuf,
384 #[source]
385 source: io::Error,
386 },
387 #[error("could not serialize config")]
388 Serialize(#[from] toml::ser::Error),
389 #[error("config at {path} has unsupported schema_version {found}")]
390 UnsupportedSchemaVersion { path: PathBuf, found: u32 },
391}
392
393#[allow(
394 clippy::result_large_err,
395 reason = "Config I/O keeps rich parse/write context and is not a hot path"
396)]
397impl Config {
398 pub fn load_or_default() -> Result<Self, ConfigError> {
401 Self::load_from_path(&paths::config_path()?)
402 }
403
404 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
407 match fs::read_to_string(path) {
408 Ok(text) => {
409 let mut config: Self =
410 toml::from_str(&text).map_err(|source| ConfigError::Parse {
411 path: path.to_path_buf(),
412 source,
413 })?;
414 if config.schema_version > SCHEMA_VERSION {
420 return Err(ConfigError::UnsupportedSchemaVersion {
421 path: path.to_path_buf(),
422 found: config.schema_version,
423 });
424 }
425 config.schema_version = SCHEMA_VERSION;
429 Ok(config)
430 }
431 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
432 Err(source) => Err(ConfigError::Read {
433 path: path.to_path_buf(),
434 source,
435 }),
436 }
437 }
438
439 pub fn save_atomic(&self) -> Result<(), ConfigError> {
443 self.save_to_path(&paths::config_path()?)
444 }
445
446 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
448 if let Some(parent) = path.parent() {
449 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
450 path: path.to_path_buf(),
451 source,
452 })?;
453 }
454 let body = toml::to_string_pretty(self)?;
455 write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
456 path: path.to_path_buf(),
457 source,
458 })
459 }
460
461 #[must_use]
464 pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
465 self.devices
466 .get(device_key)
467 .map(|d| d.bindings.clone())
468 .unwrap_or_default()
469 }
470
471 pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
476 self.devices
477 .entry(device_key.to_string())
478 .or_default()
479 .bindings
480 .insert(button, binding);
481 }
482
483 #[must_use]
488 pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
489 match self
490 .devices
491 .get(device_key)
492 .and_then(|d| d.bindings.get(&ButtonId::GestureButton))
493 {
494 Some(Binding::Gesture(map)) => map.clone(),
495 _ => BTreeMap::new(),
496 }
497 }
498
499 pub fn set_gesture_direction(
509 &mut self,
510 device_key: &str,
511 button: ButtonId,
512 direction: GestureDirection,
513 action: Action,
514 ) {
515 if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
516 map.insert(direction, action);
517 }
518 }
519
520 fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
528 let entry = self
529 .devices
530 .entry(device_key.to_string())
531 .or_default()
532 .bindings
533 .entry(button)
534 .or_insert_with(|| default_binding_for(button));
535 entry.upgrade_to_gesture();
536 entry
537 }
538
539 #[must_use]
548 pub fn gesture_owner(&self, device_key: &str) -> Option<ButtonId> {
549 let Some(device) = self.devices.get(device_key) else {
550 return Some(ButtonId::GestureButton);
552 };
553 match device.gesture_owner {
554 Some(GestureOwner::Off) => None,
555 Some(GestureOwner::Button(id)) => Some(id),
556 None => Self::infer_gesture_owner(&device.bindings),
557 }
558 }
559
560 fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
565 if let Some((id, _)) = bindings
567 .iter()
568 .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
569 {
570 return Some(*id);
571 }
572 if matches!(
574 bindings.get(&ButtonId::GestureButton),
575 Some(Binding::Single(_))
576 ) {
577 return None;
578 }
579 Some(ButtonId::GestureButton)
581 }
582
583 pub fn set_gesture_owner(&mut self, device_key: &str, button: ButtonId) {
595 self.devices
596 .entry(device_key.to_string())
597 .or_default()
598 .gesture_owner = Some(GestureOwner::Button(button));
599 self.ensure_gesture_binding(device_key, button)
600 .fill_gesture_defaults();
601 }
602
603 pub fn disable_gestures(&mut self, device_key: &str) {
607 self.devices
608 .entry(device_key.to_string())
609 .or_default()
610 .gesture_owner = Some(GestureOwner::Off);
611 }
612
613 #[must_use]
621 pub fn effective_bindings(
622 &self,
623 device_key: &str,
624 bundle_id: Option<&str>,
625 ) -> BTreeMap<ButtonId, Binding> {
626 let Some(device) = self.devices.get(device_key) else {
627 return BTreeMap::new();
628 };
629 let mut out = device.bindings.clone();
630 if let Some(bid) = bundle_id
631 && let Some(overlay) = device.per_app_bindings.get(bid)
632 {
633 for (k, v) in overlay {
634 out.insert(*k, Binding::Single(v.clone()));
635 }
636 }
637 out
638 }
639
640 pub fn set_per_app_binding(
644 &mut self,
645 device_key: &str,
646 bundle_id: &str,
647 button: ButtonId,
648 action: Option<Action>,
649 ) {
650 let entry = self
651 .devices
652 .entry(device_key.to_string())
653 .or_default()
654 .per_app_bindings
655 .entry(bundle_id.to_string())
656 .or_default();
657 match action {
658 Some(a) => {
659 entry.insert(button, a);
660 }
661 None => {
662 entry.remove(&button);
663 }
664 }
665 if let Some(d) = self.devices.get_mut(device_key) {
666 d.per_app_bindings.retain(|_, m| !m.is_empty());
667 }
668 }
669
670 #[must_use]
672 pub fn selected_device(&self) -> Option<&str> {
673 self.selected_device.as_deref()
674 }
675
676 pub fn set_selected_device(&mut self, key: Option<String>) {
679 self.selected_device = key;
680 }
681
682 #[must_use]
685 pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
686 self.devices
687 .get(device_key)
688 .map(|d| d.dpi_presets.clone())
689 .unwrap_or_default()
690 }
691
692 pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
696 self.devices
697 .entry(device_key.to_string())
698 .or_default()
699 .dpi_presets = presets;
700 }
701
702 #[must_use]
704 pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
705 self.devices
706 .get(device_key)
707 .and_then(|d| d.lighting.clone())
708 }
709
710 pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
712 self.devices
713 .entry(device_key.to_string())
714 .or_default()
715 .lighting = Some(lighting);
716 }
717}
718
719fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
720 let tmp = path.with_extension("toml.tmp");
721 {
722 #[cfg(unix)]
723 {
724 use std::os::unix::fs::OpenOptionsExt;
725 let mut f = fs::OpenOptions::new()
726 .write(true)
727 .create(true)
728 .truncate(true)
729 .mode(0o600)
730 .open(&tmp)?;
731 io::Write::write_all(&mut f, bytes)?;
732 f.sync_all()?;
733 }
734 #[cfg(not(unix))]
735 {
736 let mut f = fs::OpenOptions::new()
737 .write(true)
738 .create(true)
739 .truncate(true)
740 .open(&tmp)?;
741 io::Write::write_all(&mut f, bytes)?;
742 f.sync_all()?;
743 }
744 }
745 fs::rename(&tmp, path)
746}
747
748#[cfg(test)]
749#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
750mod tests {
751 use super::*;
752 use crate::binding::{default_binding, default_gesture_binding};
753
754 fn write_and_read(config: &Config) -> Config {
755 let dir = tempfile::tempdir().expect("tempdir");
756 let path = dir.path().join("config.toml");
757 config.save_to_path(&path).expect("save");
758 Config::load_from_path(&path).expect("load")
759 }
760
761 #[test]
762 fn missing_file_yields_default() {
763 let dir = tempfile::tempdir().expect("tempdir");
764 let path = dir.path().join("nonexistent.toml");
765 let cfg = Config::load_from_path(&path).expect("load");
766 assert_eq!(cfg.schema_version, SCHEMA_VERSION);
767 assert!(cfg.devices.is_empty());
768 }
769
770 #[test]
771 fn lighting_roundtrips_per_device() {
772 let mut cfg = Config::default();
773 cfg.set_lighting(
774 "g513",
775 Lighting {
776 enabled: true,
777 color: "00aabb".to_string(),
778 brightness: 75,
779 },
780 );
781 let restored = write_and_read(&cfg);
782 assert_eq!(
783 restored.lighting("g513"),
784 Some(Lighting {
785 enabled: true,
786 color: "00aabb".to_string(),
787 brightness: 75,
788 })
789 );
790 assert_eq!(restored.lighting("absent"), None);
791 }
792
793 #[test]
794 fn bindings_roundtrip_per_device() {
795 let mut cfg = Config::default();
796 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
797 cfg.set_binding(
798 "2b042",
799 ButtonId::DpiToggle,
800 Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
801 modifiers: crate::binding::KeyCombo::MOD_CMD,
802 key_code: 0x23, display: "⌘P".into(),
804 })),
805 );
806 cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
807
808 let parsed = write_and_read(&cfg);
809
810 let a = parsed.bindings_for("2b042");
812 assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
813 assert_eq!(
814 a.get(&ButtonId::DpiToggle),
815 Some(&Binding::Single(Action::CustomShortcut(
816 crate::binding::KeyCombo {
817 modifiers: crate::binding::KeyCombo::MOD_CMD,
818 key_code: 0x23,
819 display: "⌘P".into(),
820 }
821 )))
822 );
823
824 let b = parsed.bindings_for("4082d");
825 assert_eq!(
826 b.get(&ButtonId::Back),
827 Some(&Binding::Single(Action::Paste))
828 );
829 assert_eq!(b.len(), 1, "device b should only see its own bindings");
830
831 assert!(parsed.bindings_for("deadbeef").is_empty());
833 }
834
835 #[test]
836 fn human_readable_toml_layout() {
837 let mut cfg = Config::default();
838 cfg.set_binding(
839 "2b042",
840 ButtonId::Back,
841 Binding::Single(Action::BrowserBack),
842 );
843 let body = toml::to_string_pretty(&cfg).expect("serialize");
844
845 assert!(body.contains("schema_version = 2"), "got: {body}");
849 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
850 assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
853 }
854
855 #[test]
856 fn dpi_presets_roundtrip_per_device() {
857 let mut cfg = Config::default();
858 cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
859 cfg.set_dpi_presets("4082d", vec![400, 1600]);
860
861 let parsed = write_and_read(&cfg);
862
863 assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
864 assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
865 assert!(parsed.dpi_presets("unknown").is_empty());
866 }
867
868 #[test]
869 fn empty_dpi_presets_skip_serialization() {
870 let mut cfg = Config::default();
871 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
873 cfg.set_dpi_presets("2b042", vec![800]);
874 cfg.set_dpi_presets("2b042", vec![]); let body = toml::to_string_pretty(&cfg).expect("serialize");
877 assert!(
878 !body.contains("dpi_presets"),
879 "empty dpi_presets should be omitted: {body}"
880 );
881 }
882
883 #[test]
884 fn selected_device_roundtrips() {
885 let mut cfg = Config::default();
886 assert_eq!(cfg.selected_device(), None);
887 cfg.set_selected_device(Some("2b042".into()));
888 let parsed = write_and_read(&cfg);
889 assert_eq!(parsed.selected_device(), Some("2b042"));
890 }
891
892 #[test]
893 fn per_app_overlay_takes_precedence() {
894 let mut cfg = Config::default();
895 cfg.set_binding(
896 "2b042",
897 ButtonId::Back,
898 Binding::Single(Action::BrowserBack),
899 );
900 cfg.set_binding(
901 "2b042",
902 ButtonId::Forward,
903 Binding::Single(Action::BrowserForward),
904 );
905 cfg.set_per_app_binding(
906 "2b042",
907 "com.microsoft.VSCode",
908 ButtonId::Back,
909 Some(Action::Undo),
910 );
911
912 let global = cfg.effective_bindings("2b042", None);
914 assert_eq!(
915 global.get(&ButtonId::Back),
916 Some(&Binding::Single(Action::BrowserBack))
917 );
918 assert_eq!(
919 global.get(&ButtonId::Forward),
920 Some(&Binding::Single(Action::BrowserForward))
921 );
922
923 let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
925 assert_eq!(
926 vscode.get(&ButtonId::Back),
927 Some(&Binding::Single(Action::Undo))
928 );
929 assert_eq!(
930 vscode.get(&ButtonId::Forward),
931 Some(&Binding::Single(Action::BrowserForward))
932 );
933
934 let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
936 assert_eq!(
937 other.get(&ButtonId::Back),
938 Some(&Binding::Single(Action::BrowserBack))
939 );
940 }
941
942 #[test]
943 fn per_app_binding_removal_prunes_empty_app() {
944 let mut cfg = Config::default();
945 cfg.set_per_app_binding(
946 "2b042",
947 "com.example.App",
948 ButtonId::Back,
949 Some(Action::Copy),
950 );
951 cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
952 assert!(
953 cfg.devices["2b042"].per_app_bindings.is_empty(),
954 "removing last override should prune the app entry"
955 );
956 }
957
958 #[test]
959 fn app_settings_default_omits_block() {
960 let cfg = Config::default();
961 let body = toml::to_string_pretty(&cfg).expect("serialize");
962 assert!(
963 !body.contains("app_settings"),
964 "default app_settings should be omitted: {body}"
965 );
966 }
967
968 #[test]
969 fn app_settings_launch_at_login_roundtrips() {
970 let mut cfg = Config::default();
971 cfg.app_settings.launch_at_login = true;
972 let parsed = write_and_read(&cfg);
973 assert!(parsed.app_settings.launch_at_login);
974 }
975
976 #[test]
977 fn cleared_selected_device_omits_field() {
978 let mut cfg = Config::default();
979 cfg.set_selected_device(Some("2b042".into()));
980 cfg.set_selected_device(None);
981 let body = toml::to_string_pretty(&cfg).expect("serialize");
982 assert!(
983 !body.contains("selected_device"),
984 "cleared selection should not appear: {body}"
985 );
986 }
987
988 #[test]
989 fn empty_device_block_is_skipped_in_output() {
990 let mut cfg = Config::default();
993 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
994 cfg.devices
995 .get_mut("2b042")
996 .expect("entry")
997 .bindings
998 .clear();
999 let body = toml::to_string_pretty(&cfg).expect("serialize");
1000 assert!(
1001 !body.contains("Back"),
1002 "cleared bindings should not appear: {body}"
1003 );
1004 }
1005
1006 #[test]
1007 fn migrates_v1_button_and_gesture_bindings() {
1008 let v1 = "\
1010schema_version = 1
1011
1012[devices.2b042.button_bindings]
1013Back = \"BrowserBack\"
1014
1015[devices.2b042.gesture_bindings]
1016Up = \"Copy\"
1017Click = \"Paste\"
1018";
1019 let dir = tempfile::tempdir().expect("tempdir");
1020 let path = dir.path().join("config.toml");
1021 fs::write(&path, v1).expect("write");
1022
1023 let cfg = Config::load_from_path(&path).expect("load v1");
1025 let bindings = cfg.bindings_for("2b042");
1026 assert_eq!(
1027 bindings.get(&ButtonId::Back),
1028 Some(&Binding::Single(Action::BrowserBack))
1029 );
1030 let mut gesture = BTreeMap::new();
1031 gesture.insert(GestureDirection::Up, Action::Copy);
1032 gesture.insert(GestureDirection::Click, Action::Paste);
1033 assert_eq!(
1034 bindings.get(&ButtonId::GestureButton),
1035 Some(&Binding::Gesture(gesture))
1036 );
1037
1038 let body = toml::to_string_pretty(&cfg).expect("serialize");
1041 assert!(body.contains("schema_version = 2"), "got: {body}");
1042 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1043 assert!(!body.contains("button_bindings"), "got: {body}");
1044 assert!(!body.contains("gesture_bindings"), "got: {body}");
1045 }
1046
1047 #[test]
1048 fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
1049 let v1 = "\
1054schema_version = 1
1055
1056[devices.2b042.button_bindings]
1057GestureButton = \"MissionControl\"
1058
1059[devices.2b042.gesture_bindings]
1060Up = \"Copy\"
1061Down = \"Paste\"
1062";
1063 let dir = tempfile::tempdir().expect("tempdir");
1064 let path = dir.path().join("config.toml");
1065 fs::write(&path, v1).expect("write");
1066
1067 let cfg = Config::load_from_path(&path).expect("load v1");
1068 let mut gesture = BTreeMap::new();
1069 gesture.insert(GestureDirection::Up, Action::Copy);
1070 gesture.insert(GestureDirection::Down, Action::Paste);
1071 assert_eq!(
1072 cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
1073 Some(&Binding::Gesture(gesture)),
1074 "gesture map must win over the legacy single GestureButton entry"
1075 );
1076 }
1077
1078 #[test]
1079 fn migration_drops_vestigial_lone_gesture_button_single() {
1080 let v1 = "\
1087schema_version = 1
1088
1089[devices.2b042.button_bindings]
1090GestureButton = \"MissionControl\"
1091Back = \"BrowserBack\"
1092";
1093 let dir = tempfile::tempdir().expect("tempdir");
1094 let path = dir.path().join("config.toml");
1095 fs::write(&path, v1).expect("write");
1096
1097 let bindings = Config::load_from_path(&path)
1098 .expect("load v1")
1099 .bindings_for("2b042");
1100 assert_eq!(
1102 bindings.get(&ButtonId::Back),
1103 Some(&Binding::Single(Action::BrowserBack))
1104 );
1105 assert_eq!(bindings.get(&ButtonId::GestureButton), None);
1108 }
1109
1110 #[test]
1111 fn rejects_newer_schema_version_but_accepts_v1() {
1112 let dir = tempfile::tempdir().expect("tempdir");
1115 let path = dir.path().join("config.toml");
1116 fs::write(&path, "schema_version = 99\n").expect("write");
1117 assert!(matches!(
1118 Config::load_from_path(&path).expect_err("v99 should fail"),
1119 ConfigError::UnsupportedSchemaVersion { found: 99, .. }
1120 ));
1121
1122 fs::write(&path, "schema_version = 1\n").expect("write");
1123 assert!(
1124 Config::load_from_path(&path).is_ok(),
1125 "v1 should still load"
1126 );
1127 }
1128
1129 #[test]
1130 fn set_gesture_direction_upgrades_single_to_gesture() {
1131 let mut cfg = Config::default();
1132 cfg.set_binding(
1134 "2b042",
1135 ButtonId::Back,
1136 Binding::Single(Action::BrowserBack),
1137 );
1138 cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
1139
1140 match cfg.bindings_for("2b042").get(&ButtonId::Back) {
1141 Some(Binding::Gesture(map)) => {
1142 assert_eq!(
1144 map.get(&GestureDirection::Click),
1145 Some(&Action::BrowserBack)
1146 );
1147 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1148 }
1149 other => panic!("expected Gesture after upgrade, got {other:?}"),
1150 }
1151 }
1152
1153 #[test]
1154 fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
1155 let mut cfg = Config::default();
1159 cfg.set_gesture_direction(
1160 "2b042",
1161 ButtonId::GestureButton,
1162 GestureDirection::Up,
1163 Action::Copy,
1164 );
1165
1166 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1167 Some(Binding::Gesture(map)) => {
1168 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1169 assert_eq!(
1170 map.get(&GestureDirection::Click),
1171 Some(&crate::binding::default_gesture_binding(
1172 GestureDirection::Click
1173 )),
1174 "a fresh gesture button must seed a Click from its default"
1175 );
1176 }
1177 other => panic!("expected Gesture, got {other:?}"),
1178 }
1179 }
1180
1181 #[test]
1182 fn gesture_owner_defaults_to_thumb_pad_yields_to_oshook_and_can_be_off() {
1183 let mut cfg = Config::default();
1184 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1186
1187 cfg.set_gesture_direction(
1189 "2b042",
1190 ButtonId::GestureButton,
1191 GestureDirection::Up,
1192 Action::MissionControl,
1193 );
1194 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1195
1196 cfg.set_binding(
1198 "2b042",
1199 ButtonId::Forward,
1200 Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
1201 );
1202 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
1203
1204 let mut off = Config::default();
1206 off.disable_gestures("2b042");
1207 assert_eq!(off.gesture_owner("2b042"), None);
1208 }
1209
1210 #[test]
1211 fn set_gesture_owner_records_owner_without_destroying_other_maps() {
1212 let mut cfg = Config::default();
1213 cfg.set_gesture_direction(
1215 "2b042",
1216 ButtonId::GestureButton,
1217 GestureDirection::Up,
1218 Action::Copy,
1219 );
1220 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1221
1222 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
1225 cfg.set_gesture_owner("2b042", ButtonId::Back);
1226 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
1227
1228 let bindings = cfg.bindings_for("2b042");
1229 match bindings.get(&ButtonId::Back) {
1232 Some(Binding::Gesture(map)) => {
1233 assert_eq!(
1234 map.get(&GestureDirection::Click),
1235 Some(&Action::BrowserBack)
1236 );
1237 assert_eq!(
1238 map.get(&GestureDirection::Up),
1239 Some(&default_gesture_binding(GestureDirection::Up)),
1240 "a promoted button gets full default arms"
1241 );
1242 }
1243 other => panic!("expected Back to be a gesture binding, got {other:?}"),
1244 }
1245 match bindings.get(&ButtonId::GestureButton) {
1247 Some(Binding::Gesture(map)) => {
1248 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1249 }
1250 other => panic!("expected the thumb pad map preserved, got {other:?}"),
1251 }
1252
1253 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1256 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1257 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1258 Some(Binding::Gesture(map)) => {
1259 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1260 }
1261 other => panic!("expected preserved gesture map, got {other:?}"),
1262 }
1263 }
1264
1265 #[test]
1266 fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
1267 let mut cfg = Config::default();
1268 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1270 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1271 Some(Binding::Gesture(map)) => {
1272 for dir in GestureDirection::ALL {
1273 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1274 }
1275 }
1276 other => panic!("expected full default gesture map, got {other:?}"),
1277 }
1278
1279 cfg.set_gesture_owner("2b042", ButtonId::Forward);
1283 match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
1284 Some(Binding::Gesture(map)) => {
1285 assert_eq!(
1286 map.get(&GestureDirection::Click),
1287 Some(&default_binding(ButtonId::Forward))
1288 );
1289 for dir in [
1290 GestureDirection::Up,
1291 GestureDirection::Down,
1292 GestureDirection::Left,
1293 GestureDirection::Right,
1294 ] {
1295 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1296 }
1297 }
1298 other => panic!("expected full gesture map for Forward, got {other:?}"),
1299 }
1300 }
1301
1302 #[test]
1303 fn disable_gestures_turns_off_without_destroying_maps() {
1304 let mut cfg = Config::default();
1305 cfg.set_gesture_direction(
1306 "2b042",
1307 ButtonId::GestureButton,
1308 GestureDirection::Up,
1309 Action::Copy,
1310 );
1311 cfg.disable_gestures("2b042");
1312 assert_eq!(cfg.gesture_owner("2b042"), None);
1315 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1316 Some(Binding::Gesture(map)) => {
1317 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1318 }
1319 other => panic!("expected the gesture map preserved while off, got {other:?}"),
1320 }
1321 }
1322
1323 #[test]
1324 fn gesture_owner_field_roundtrips_as_a_scalar() {
1325 let mut cfg = Config::default();
1326 cfg.set_gesture_owner("2b042", ButtonId::Back); cfg.disable_gestures("4082d"); let parsed = write_and_read(&cfg);
1330 assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
1331 assert_eq!(parsed.gesture_owner("4082d"), None);
1332
1333 let body = toml::to_string_pretty(&cfg).expect("serialize");
1336 assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
1337 assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
1338 }
1339
1340 #[test]
1341 fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
1342 let toml = "\
1346schema_version = 2
1347
1348[devices.2b042]
1349gesture_owner = \"bogus\"
1350
1351[devices.2b042.bindings]
1352Back = \"Copy\"
1353";
1354 let dir = tempfile::tempdir().expect("tempdir");
1355 let path = dir.path().join("config.toml");
1356 fs::write(&path, toml).expect("write");
1357
1358 let cfg =
1359 Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
1360 assert_eq!(
1362 cfg.bindings_for("2b042").get(&ButtonId::Back),
1363 Some(&Binding::Single(Action::Copy))
1364 );
1365 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1367 }
1368}