use std::{
collections::BTreeMap,
fs, io,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::binding::{Action, Binding, ButtonId, GestureDirection, default_binding_for};
use crate::device::{Capabilities, DeviceKind, DeviceModelInfo};
use crate::paths::{self, PathsError};
pub const SCHEMA_VERSION: u32 = 3;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub schema_version: u32,
#[serde(default, skip_serializing_if = "AppSettings::is_default")]
pub app_settings: AppSettings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_device: Option<String>,
#[serde(default)]
pub devices: BTreeMap<String, DeviceConfig>,
}
impl Default for Config {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
app_settings: AppSettings::default(),
selected_device: None,
devices: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(
clippy::struct_excessive_bools,
reason = "independent on/off user preferences, not a state machine"
)]
pub struct AppSettings {
#[serde(default)]
pub launch_at_login: bool,
#[serde(default)]
pub check_for_updates: bool,
#[serde(default)]
pub update_prompt_seen: bool,
#[serde(default = "default_true")]
pub show_in_menu_bar: bool,
#[serde(default = "default_true")]
pub auto_download_assets: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(default = "default_thumbwheel_sensitivity")]
pub thumbwheel_sensitivity: i32,
}
pub const DEFAULT_THUMBWHEEL_SENSITIVITY: i32 = 14;
pub const MIN_THUMBWHEEL_SENSITIVITY: i32 = 1;
pub const MAX_THUMBWHEEL_SENSITIVITY: i32 = 100;
impl AppSettings {
#[must_use]
pub fn is_default(&self) -> bool {
self == &Self::default()
}
}
impl Default for AppSettings {
fn default() -> Self {
Self {
launch_at_login: false,
check_for_updates: false,
update_prompt_seen: false,
show_in_menu_bar: true,
auto_download_assets: true,
language: None,
thumbwheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY,
}
}
}
fn default_true() -> bool {
true
}
const fn default_thumbwheel_sensitivity() -> i32 {
DEFAULT_THUMBWHEEL_SENSITIVITY
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lighting {
#[serde(default = "default_lighting_enabled")]
pub enabled: bool,
#[serde(default = "default_lighting_color")]
pub color: String,
#[serde(
default = "default_lighting_brightness",
deserialize_with = "deserialize_brightness"
)]
pub brightness: u8,
}
impl Default for Lighting {
fn default() -> Self {
Self {
enabled: default_lighting_enabled(),
color: default_lighting_color(),
brightness: default_lighting_brightness(),
}
}
}
fn default_lighting_enabled() -> bool {
true
}
fn default_lighting_color() -> String {
"ffffff".to_string()
}
fn default_lighting_brightness() -> u8 {
100
}
fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(u8::deserialize(deserializer)?.min(100))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WheelMode {
Free,
Ratchet,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SmartShift {
pub mode: WheelMode,
pub auto_disengage: u8,
pub tunable_torque: u8,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GestureOwner {
Off,
Button(ButtonId),
}
impl Serialize for GestureOwner {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
GestureOwner::Off => serializer.serialize_str("Off"),
GestureOwner::Button(id) => id.serialize(serializer),
}
}
}
fn deserialize_gesture_owner<'de, D>(deserializer: D) -> Result<Option<GestureOwner>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if s == "Off" {
return Ok(Some(GestureOwner::Off));
}
let button = ButtonId::deserialize(
serde::de::value::StrDeserializer::<serde::de::value::Error>::new(&s),
)
.ok();
Ok(button.map(GestureOwner::Button))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeviceIdentity {
pub display_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_info: Option<DeviceModelInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codename: Option<String>,
pub kind: DeviceKind,
pub capabilities: Capabilities,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(from = "RawDeviceConfig")]
pub struct DeviceConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gesture_owner: Option<GestureOwner>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<DeviceIdentity>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub bindings: BTreeMap<ButtonId, Binding>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dpi_presets: Vec<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dpi: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lighting: Option<Lighting>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smartshift: Option<SmartShift>,
#[serde(default, skip_serializing_if = "is_false")]
pub invert_scroll: bool,
}
#[allow(
clippy::trivially_copy_pass_by_ref,
reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
)]
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Deserialize)]
struct RawDeviceConfig {
#[serde(default, deserialize_with = "deserialize_gesture_owner")]
gesture_owner: Option<GestureOwner>,
#[serde(default)]
identity: Option<DeviceIdentity>,
#[serde(default)]
bindings: BTreeMap<ButtonId, Binding>,
#[serde(default)]
button_bindings: BTreeMap<ButtonId, Action>,
#[serde(default)]
gesture_bindings: BTreeMap<GestureDirection, Action>,
#[serde(default)]
per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
#[serde(default)]
dpi_presets: Vec<u32>,
#[serde(default)]
dpi: Option<u32>,
#[serde(default)]
lighting: Option<Lighting>,
#[serde(default)]
smartshift: Option<SmartShift>,
#[serde(default)]
invert_scroll: bool,
}
impl From<RawDeviceConfig> for DeviceConfig {
fn from(raw: RawDeviceConfig) -> Self {
let mut bindings = raw.bindings;
if !raw.gesture_bindings.is_empty() {
bindings
.entry(ButtonId::GestureButton)
.or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
}
for (button, action) in raw.button_bindings {
if button == ButtonId::GestureButton {
continue;
}
bindings.entry(button).or_insert(Binding::Single(action));
}
DeviceConfig {
gesture_owner: raw.gesture_owner,
identity: raw.identity,
bindings,
per_app_bindings: raw.per_app_bindings,
dpi_presets: raw.dpi_presets,
dpi: raw.dpi,
lighting: raw.lighting,
smartshift: raw.smartshift,
invert_scroll: raw.invert_scroll,
}
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("could not resolve config path")]
Path(#[from] PathsError),
#[error("could not read config at {path}")]
Read {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("could not parse config at {path}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("could not write config at {path}")]
Write {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("could not serialize config")]
Serialize(#[from] toml::ser::Error),
#[error("config at {path} has unsupported schema_version {found}")]
UnsupportedSchemaVersion { path: PathBuf, found: u32 },
}
#[allow(
clippy::result_large_err,
reason = "Config I/O keeps rich parse/write context and is not a hot path"
)]
impl Config {
pub fn load_or_default() -> Result<Self, ConfigError> {
Self::load_from_path(&paths::config_path()?)
}
pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
match fs::read_to_string(path) {
Ok(text) => {
let mut config: Self =
toml::from_str(&text).map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
source,
})?;
if config.schema_version > SCHEMA_VERSION {
return Err(ConfigError::UnsupportedSchemaVersion {
path: path.to_path_buf(),
found: config.schema_version,
});
}
config.schema_version = SCHEMA_VERSION;
Ok(config)
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(source) => Err(ConfigError::Read {
path: path.to_path_buf(),
source,
}),
}
}
pub fn save_atomic(&self) -> Result<(), ConfigError> {
self.save_to_path(&paths::config_path()?)
}
pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
path: path.to_path_buf(),
source,
})?;
}
let body = toml::to_string_pretty(self)?;
write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
path: path.to_path_buf(),
source,
})
}
#[must_use]
pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
self.devices
.get(device_key)
.map(|d| d.bindings.clone())
.unwrap_or_default()
}
pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
self.devices
.entry(device_key.to_string())
.or_default()
.bindings
.insert(button, binding);
}
#[must_use]
pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
match self
.devices
.get(device_key)
.and_then(|d| d.bindings.get(&ButtonId::GestureButton))
{
Some(Binding::Gesture(map)) => map.clone(),
_ => BTreeMap::new(),
}
}
pub fn set_gesture_direction(
&mut self,
device_key: &str,
button: ButtonId,
direction: GestureDirection,
action: Action,
) {
if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
map.insert(direction, action);
}
}
fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
let entry = self
.devices
.entry(device_key.to_string())
.or_default()
.bindings
.entry(button)
.or_insert_with(|| default_binding_for(button));
entry.upgrade_to_gesture();
entry
}
#[must_use]
pub fn gesture_owner(&self, device_key: &str) -> Option<ButtonId> {
let Some(device) = self.devices.get(device_key) else {
return Some(ButtonId::GestureButton);
};
match device.gesture_owner {
Some(GestureOwner::Off) => None,
Some(GestureOwner::Button(id)) => Some(id),
None => Self::infer_gesture_owner(&device.bindings),
}
}
fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
if let Some((id, _)) = bindings
.iter()
.find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
{
return Some(*id);
}
if matches!(
bindings.get(&ButtonId::GestureButton),
Some(Binding::Single(_))
) {
return None;
}
Some(ButtonId::GestureButton)
}
pub fn set_gesture_owner(&mut self, device_key: &str, button: ButtonId) {
self.devices
.entry(device_key.to_string())
.or_default()
.gesture_owner = Some(GestureOwner::Button(button));
self.ensure_gesture_binding(device_key, button)
.fill_gesture_defaults();
}
pub fn disable_gestures(&mut self, device_key: &str) {
self.devices
.entry(device_key.to_string())
.or_default()
.gesture_owner = Some(GestureOwner::Off);
}
#[must_use]
pub fn effective_bindings(
&self,
device_key: &str,
bundle_id: Option<&str>,
) -> BTreeMap<ButtonId, Binding> {
let Some(device) = self.devices.get(device_key) else {
return BTreeMap::new();
};
let mut out = device.bindings.clone();
if let Some(bid) = bundle_id
&& let Some(overlay) = device.per_app_bindings.get(bid)
{
for (k, v) in overlay {
out.insert(*k, Binding::Single(v.clone()));
}
}
out
}
pub fn set_per_app_binding(
&mut self,
device_key: &str,
bundle_id: &str,
button: ButtonId,
action: Option<Action>,
) {
let entry = self
.devices
.entry(device_key.to_string())
.or_default()
.per_app_bindings
.entry(bundle_id.to_string())
.or_default();
match action {
Some(a) => {
entry.insert(button, a);
}
None => {
entry.remove(&button);
}
}
if let Some(d) = self.devices.get_mut(device_key) {
d.per_app_bindings.retain(|_, m| !m.is_empty());
}
}
#[must_use]
pub fn selected_device(&self) -> Option<&str> {
self.selected_device.as_deref()
}
pub fn set_selected_device(&mut self, key: Option<String>) {
self.selected_device = key;
}
#[must_use]
pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
self.devices
.get(device_key)
.map(|d| d.dpi_presets.clone())
.unwrap_or_default()
}
pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
self.devices
.entry(device_key.to_string())
.or_default()
.dpi_presets = presets;
}
#[must_use]
pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
self.devices
.get(device_key)
.and_then(|d| d.identity.as_ref())
}
pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
self.devices
.entry(device_key.to_string())
.or_default()
.identity = Some(identity);
}
pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
self.devices
.iter()
.filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
}
#[must_use]
pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
self.devices
.get(device_key)
.and_then(|d| d.lighting.clone())
}
pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
self.devices
.entry(device_key.to_string())
.or_default()
.lighting = Some(lighting);
}
#[must_use]
pub fn dpi(&self, device_key: &str) -> Option<u32> {
self.devices.get(device_key).and_then(|d| d.dpi)
}
pub fn set_dpi(&mut self, device_key: &str, dpi: u32) {
self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
}
#[must_use]
pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
self.devices.get(device_key).and_then(|d| d.smartshift)
}
pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
self.devices
.entry(device_key.to_string())
.or_default()
.smartshift = Some(smartshift);
}
#[must_use]
pub fn invert_scroll(&self, device_key: &str) -> bool {
self.devices
.get(device_key)
.is_some_and(|d| d.invert_scroll)
}
pub fn set_invert_scroll(&mut self, device_key: &str, invert: bool) {
self.devices
.entry(device_key.to_string())
.or_default()
.invert_scroll = invert;
}
}
fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let tmp = path.with_extension("toml.tmp");
{
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut f = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)?;
io::Write::write_all(&mut f, bytes)?;
f.sync_all()?;
}
#[cfg(not(unix))]
{
let mut f = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp)?;
io::Write::write_all(&mut f, bytes)?;
f.sync_all()?;
}
}
fs::rename(&tmp, path)
}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
mod tests {
use super::*;
use crate::binding::{default_binding, default_gesture_binding};
fn write_and_read(config: &Config) -> Config {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
config.save_to_path(&path).expect("save");
Config::load_from_path(&path).expect("load")
}
#[test]
fn missing_file_yields_default() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("nonexistent.toml");
let cfg = Config::load_from_path(&path).expect("load");
assert_eq!(cfg.schema_version, SCHEMA_VERSION);
assert!(cfg.devices.is_empty());
}
#[test]
fn lighting_roundtrips_per_device() {
let mut cfg = Config::default();
cfg.set_lighting(
"g513",
Lighting {
enabled: true,
color: "00aabb".to_string(),
brightness: 75,
},
);
let restored = write_and_read(&cfg);
assert_eq!(
restored.lighting("g513"),
Some(Lighting {
enabled: true,
color: "00aabb".to_string(),
brightness: 75,
})
);
assert_eq!(restored.lighting("absent"), None);
}
#[test]
fn dpi_roundtrips_per_device() {
let mut cfg = Config::default();
cfg.set_dpi("2b042", 1600);
let restored = write_and_read(&cfg);
assert_eq!(restored.dpi("2b042"), Some(1600));
assert_eq!(restored.dpi("absent"), None);
}
#[test]
fn smartshift_roundtrips_per_device() {
let mut cfg = Config::default();
cfg.set_smartshift(
"2b042",
SmartShift {
mode: WheelMode::Ratchet,
auto_disengage: 16,
tunable_torque: 30,
},
);
let restored = write_and_read(&cfg);
assert_eq!(
restored.smartshift("2b042"),
Some(SmartShift {
mode: WheelMode::Ratchet,
auto_disengage: 16,
tunable_torque: 30,
})
);
assert_eq!(restored.smartshift("absent"), None);
}
#[test]
fn invert_scroll_roundtrips_per_device() {
let mut cfg = Config::default();
assert!(!cfg.invert_scroll("2b042"));
cfg.set_invert_scroll("2b042", true);
let restored = write_and_read(&cfg);
assert!(restored.invert_scroll("2b042"));
assert!(!restored.invert_scroll("absent"));
}
#[test]
fn default_invert_scroll_is_omitted_from_toml() {
let mut cfg = Config::default();
cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
cfg.set_invert_scroll("2b042", false);
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(
!body.contains("invert_scroll"),
"default invert_scroll should be omitted: {body}"
);
}
#[test]
fn bindings_roundtrip_per_device() {
let mut cfg = Config::default();
cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
cfg.set_binding(
"2b042",
ButtonId::DpiToggle,
Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
modifiers: crate::binding::KeyCombo::MOD_CMD,
key_code: 0x23, display: "⌘P".into(),
})),
);
cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
let parsed = write_and_read(&cfg);
let a = parsed.bindings_for("2b042");
assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
assert_eq!(
a.get(&ButtonId::DpiToggle),
Some(&Binding::Single(Action::CustomShortcut(
crate::binding::KeyCombo {
modifiers: crate::binding::KeyCombo::MOD_CMD,
key_code: 0x23,
display: "⌘P".into(),
}
)))
);
let b = parsed.bindings_for("4082d");
assert_eq!(
b.get(&ButtonId::Back),
Some(&Binding::Single(Action::Paste))
);
assert_eq!(b.len(), 1, "device b should only see its own bindings");
assert!(parsed.bindings_for("deadbeef").is_empty());
}
#[test]
fn human_readable_toml_layout() {
let mut cfg = Config::default();
cfg.set_binding(
"2b042",
ButtonId::Back,
Binding::Single(Action::BrowserBack),
);
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(body.contains("schema_version = 3"), "got: {body}");
assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
}
#[test]
fn dpi_presets_roundtrip_per_device() {
let mut cfg = Config::default();
cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
cfg.set_dpi_presets("4082d", vec![400, 1600]);
let parsed = write_and_read(&cfg);
assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
assert!(parsed.dpi_presets("unknown").is_empty());
}
#[test]
fn empty_dpi_presets_skip_serialization() {
let mut cfg = Config::default();
cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
cfg.set_dpi_presets("2b042", vec![800]);
cfg.set_dpi_presets("2b042", vec![]);
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(
!body.contains("dpi_presets"),
"empty dpi_presets should be omitted: {body}"
);
}
#[test]
fn device_identity_roundtrips_and_is_iterable() {
use crate::device::{Capabilities, DeviceKind};
let mut cfg = Config::default();
let mouse = DeviceIdentity {
display_name: "MX Master 3S".to_string(),
model_info: None,
codename: None,
kind: DeviceKind::Mouse,
capabilities: Capabilities {
buttons: true,
pointer: true,
lighting: false,
scroll_inversion: false,
},
};
cfg.set_device_identity("2b034", mouse.clone());
cfg.set_binding(
"2b034",
ButtonId::Back,
Binding::Single(Action::BrowserBack),
);
let parsed = write_and_read(&cfg);
assert_eq!(parsed.device_identity("2b034"), Some(&mouse));
assert_eq!(parsed.device_identity("absent"), None);
assert_eq!(
parsed.bindings_for("2b034").get(&ButtonId::Back),
Some(&Binding::Single(Action::BrowserBack)),
"identity must coexist with bindings on the same device block"
);
assert_eq!(
parsed.known_identities().collect::<Vec<_>>(),
vec![("2b034", &mouse)]
);
}
#[test]
fn selected_device_roundtrips() {
let mut cfg = Config::default();
assert_eq!(cfg.selected_device(), None);
cfg.set_selected_device(Some("2b042".into()));
let parsed = write_and_read(&cfg);
assert_eq!(parsed.selected_device(), Some("2b042"));
}
#[test]
fn per_app_overlay_takes_precedence() {
let mut cfg = Config::default();
cfg.set_binding(
"2b042",
ButtonId::Back,
Binding::Single(Action::BrowserBack),
);
cfg.set_binding(
"2b042",
ButtonId::Forward,
Binding::Single(Action::BrowserForward),
);
cfg.set_per_app_binding(
"2b042",
"com.microsoft.VSCode",
ButtonId::Back,
Some(Action::Undo),
);
let global = cfg.effective_bindings("2b042", None);
assert_eq!(
global.get(&ButtonId::Back),
Some(&Binding::Single(Action::BrowserBack))
);
assert_eq!(
global.get(&ButtonId::Forward),
Some(&Binding::Single(Action::BrowserForward))
);
let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
assert_eq!(
vscode.get(&ButtonId::Back),
Some(&Binding::Single(Action::Undo))
);
assert_eq!(
vscode.get(&ButtonId::Forward),
Some(&Binding::Single(Action::BrowserForward))
);
let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
assert_eq!(
other.get(&ButtonId::Back),
Some(&Binding::Single(Action::BrowserBack))
);
}
#[test]
fn per_app_binding_removal_prunes_empty_app() {
let mut cfg = Config::default();
cfg.set_per_app_binding(
"2b042",
"com.example.App",
ButtonId::Back,
Some(Action::Copy),
);
cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
assert!(
cfg.devices["2b042"].per_app_bindings.is_empty(),
"removing last override should prune the app entry"
);
}
#[test]
fn app_settings_default_omits_block() {
let cfg = Config::default();
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(
!body.contains("app_settings"),
"default app_settings should be omitted: {body}"
);
}
#[test]
fn app_settings_launch_at_login_roundtrips() {
let mut cfg = Config::default();
cfg.app_settings.launch_at_login = true;
let parsed = write_and_read(&cfg);
assert!(parsed.app_settings.launch_at_login);
}
#[test]
fn cleared_selected_device_omits_field() {
let mut cfg = Config::default();
cfg.set_selected_device(Some("2b042".into()));
cfg.set_selected_device(None);
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(
!body.contains("selected_device"),
"cleared selection should not appear: {body}"
);
}
#[test]
fn empty_device_block_is_skipped_in_output() {
let mut cfg = Config::default();
cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
cfg.devices
.get_mut("2b042")
.expect("entry")
.bindings
.clear();
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(
!body.contains("Back"),
"cleared bindings should not appear: {body}"
);
}
#[test]
fn migrates_v1_button_and_gesture_bindings() {
let v1 = "\
schema_version = 1
[devices.2b042.button_bindings]
Back = \"BrowserBack\"
[devices.2b042.gesture_bindings]
Up = \"Copy\"
Click = \"Paste\"
";
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(&path, v1).expect("write");
let cfg = Config::load_from_path(&path).expect("load v1");
let bindings = cfg.bindings_for("2b042");
assert_eq!(
bindings.get(&ButtonId::Back),
Some(&Binding::Single(Action::BrowserBack))
);
let mut gesture = BTreeMap::new();
gesture.insert(GestureDirection::Up, Action::Copy);
gesture.insert(GestureDirection::Click, Action::Paste);
assert_eq!(
bindings.get(&ButtonId::GestureButton),
Some(&Binding::Gesture(gesture))
);
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(body.contains("schema_version = 3"), "got: {body}");
assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
assert!(!body.contains("button_bindings"), "got: {body}");
assert!(!body.contains("gesture_bindings"), "got: {body}");
}
#[test]
fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
let v1 = "\
schema_version = 1
[devices.2b042.button_bindings]
GestureButton = \"MissionControl\"
[devices.2b042.gesture_bindings]
Up = \"Copy\"
Down = \"Paste\"
";
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(&path, v1).expect("write");
let cfg = Config::load_from_path(&path).expect("load v1");
let mut gesture = BTreeMap::new();
gesture.insert(GestureDirection::Up, Action::Copy);
gesture.insert(GestureDirection::Down, Action::Paste);
assert_eq!(
cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
Some(&Binding::Gesture(gesture)),
"gesture map must win over the legacy single GestureButton entry"
);
}
#[test]
fn migration_drops_vestigial_lone_gesture_button_single() {
let v1 = "\
schema_version = 1
[devices.2b042.button_bindings]
GestureButton = \"MissionControl\"
Back = \"BrowserBack\"
";
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(&path, v1).expect("write");
let bindings = Config::load_from_path(&path)
.expect("load v1")
.bindings_for("2b042");
assert_eq!(
bindings.get(&ButtonId::Back),
Some(&Binding::Single(Action::BrowserBack))
);
assert_eq!(bindings.get(&ButtonId::GestureButton), None);
}
#[test]
fn rejects_newer_schema_version_but_accepts_v1() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(&path, "schema_version = 99\n").expect("write");
assert!(matches!(
Config::load_from_path(&path).expect_err("v99 should fail"),
ConfigError::UnsupportedSchemaVersion { found: 99, .. }
));
fs::write(&path, "schema_version = 1\n").expect("write");
assert!(
Config::load_from_path(&path).is_ok(),
"v1 should still load"
);
}
#[test]
fn set_gesture_direction_upgrades_single_to_gesture() {
let mut cfg = Config::default();
cfg.set_binding(
"2b042",
ButtonId::Back,
Binding::Single(Action::BrowserBack),
);
cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
match cfg.bindings_for("2b042").get(&ButtonId::Back) {
Some(Binding::Gesture(map)) => {
assert_eq!(
map.get(&GestureDirection::Click),
Some(&Action::BrowserBack)
);
assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
}
other => panic!("expected Gesture after upgrade, got {other:?}"),
}
}
#[test]
fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
let mut cfg = Config::default();
cfg.set_gesture_direction(
"2b042",
ButtonId::GestureButton,
GestureDirection::Up,
Action::Copy,
);
match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
assert_eq!(
map.get(&GestureDirection::Click),
Some(&crate::binding::default_gesture_binding(
GestureDirection::Click
)),
"a fresh gesture button must seed a Click from its default"
);
}
other => panic!("expected Gesture, got {other:?}"),
}
}
#[test]
fn gesture_owner_defaults_to_thumb_pad_yields_to_oshook_and_can_be_off() {
let mut cfg = Config::default();
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
cfg.set_gesture_direction(
"2b042",
ButtonId::GestureButton,
GestureDirection::Up,
Action::MissionControl,
);
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
cfg.set_binding(
"2b042",
ButtonId::Forward,
Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
);
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
let mut off = Config::default();
off.disable_gestures("2b042");
assert_eq!(off.gesture_owner("2b042"), None);
}
#[test]
fn set_gesture_owner_records_owner_without_destroying_other_maps() {
let mut cfg = Config::default();
cfg.set_gesture_direction(
"2b042",
ButtonId::GestureButton,
GestureDirection::Up,
Action::Copy,
);
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
cfg.set_gesture_owner("2b042", ButtonId::Back);
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
let bindings = cfg.bindings_for("2b042");
match bindings.get(&ButtonId::Back) {
Some(Binding::Gesture(map)) => {
assert_eq!(
map.get(&GestureDirection::Click),
Some(&Action::BrowserBack)
);
assert_eq!(
map.get(&GestureDirection::Up),
Some(&default_gesture_binding(GestureDirection::Up)),
"a promoted button gets full default arms"
);
}
other => panic!("expected Back to be a gesture binding, got {other:?}"),
}
match bindings.get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
}
other => panic!("expected the thumb pad map preserved, got {other:?}"),
}
cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
}
other => panic!("expected preserved gesture map, got {other:?}"),
}
}
#[test]
fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
let mut cfg = Config::default();
cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
for dir in GestureDirection::ALL {
assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
}
}
other => panic!("expected full default gesture map, got {other:?}"),
}
cfg.set_gesture_owner("2b042", ButtonId::Forward);
match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
Some(Binding::Gesture(map)) => {
assert_eq!(
map.get(&GestureDirection::Click),
Some(&default_binding(ButtonId::Forward))
);
for dir in [
GestureDirection::Up,
GestureDirection::Down,
GestureDirection::Left,
GestureDirection::Right,
] {
assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
}
}
other => panic!("expected full gesture map for Forward, got {other:?}"),
}
}
#[test]
fn disable_gestures_turns_off_without_destroying_maps() {
let mut cfg = Config::default();
cfg.set_gesture_direction(
"2b042",
ButtonId::GestureButton,
GestureDirection::Up,
Action::Copy,
);
cfg.disable_gestures("2b042");
assert_eq!(cfg.gesture_owner("2b042"), None);
match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
}
other => panic!("expected the gesture map preserved while off, got {other:?}"),
}
}
#[test]
fn gesture_owner_field_roundtrips_as_a_scalar() {
let mut cfg = Config::default();
cfg.set_gesture_owner("2b042", ButtonId::Back); cfg.disable_gestures("4082d");
let parsed = write_and_read(&cfg);
assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
assert_eq!(parsed.gesture_owner("4082d"), None);
let body = toml::to_string_pretty(&cfg).expect("serialize");
assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
}
#[test]
fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
let toml = "\
schema_version = 2
[devices.2b042]
gesture_owner = \"bogus\"
[devices.2b042.bindings]
Back = \"Copy\"
";
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
fs::write(&path, toml).expect("write");
let cfg =
Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
assert_eq!(
cfg.bindings_for("2b042").get(&ButtonId::Back),
Some(&Binding::Single(Action::Copy))
);
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
}
}