use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct Config {
pub hotkeys: HotkeyBindings,
pub hints: HintConfig,
pub colors: ColorConfig,
pub startup: StartupConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct HotkeyBindings {
pub pick_element: String,
pub pick_window: String,
}
impl Default for HotkeyBindings {
fn default() -> Self {
Self {
pick_element: "Ctrl+Shift+Space".to_string(),
pick_window: "Ctrl+Alt+Space".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct HintConfig {
pub alphabet: String,
}
impl Default for HintConfig {
fn default() -> Self {
Self {
alphabet: crate::hint::DEFAULT_ALPHABET.to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct ColorConfig {
pub element: BadgeColors,
pub window: BadgeColors,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct BadgeColors {
pub badge_bg: String,
pub badge_fg: String,
pub border: String,
pub opacity: u8,
pub show_leader: Option<bool>,
pub leader_color: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct StartupConfig {
pub launch_at_startup: bool,
}
impl Config {
pub fn file_path() -> Option<PathBuf> {
let appdata = std::env::var_os("APPDATA")?;
let mut path = PathBuf::from(appdata);
path.push("keyhop");
path.push("config.toml");
Some(path)
}
pub fn load_or_default() -> Self {
match Self::try_load() {
Ok(Some(cfg)) => {
tracing::info!("loaded config from disk");
cfg
}
Ok(None) => {
tracing::info!("no config file found, using defaults");
Self::default()
}
Err(e) => {
tracing::warn!(error = ?e, "config file is invalid, using defaults");
Self::default()
}
}
}
pub fn try_load() -> anyhow::Result<Option<Self>> {
let Some(path) = Self::file_path() else {
return Ok(None);
};
if !path.exists() {
return Ok(None);
}
let text = std::fs::read_to_string(&path)?;
let cfg: Config = toml::from_str(&text)?;
Ok(Some(cfg))
}
pub fn save(&self) -> anyhow::Result<()> {
let path = Self::file_path()
.ok_or_else(|| anyhow::anyhow!("APPDATA env var not set; cannot save config"))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = toml::to_string_pretty(self)?;
std::fs::write(&path, text)?;
tracing::info!(?path, "config saved");
Ok(())
}
pub fn delete_file() -> anyhow::Result<()> {
let Some(path) = Self::file_path() else {
return Ok(());
};
if path.exists() {
std::fs::remove_file(&path)?;
tracing::info!(?path, "config file deleted");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_round_trip_through_toml() {
let cfg = Config::default();
let text = toml::to_string_pretty(&cfg).unwrap();
let parsed: Config = toml::from_str(&text).unwrap();
assert_eq!(cfg, parsed);
}
#[test]
fn missing_sections_fall_back_to_defaults() {
let text = "[hotkeys]\npick_element = \"Ctrl+Alt+K\"\n";
let cfg: Config = toml::from_str(text).unwrap();
assert_eq!(cfg.hotkeys.pick_element, "Ctrl+Alt+K");
assert_eq!(cfg.hotkeys.pick_window, "Ctrl+Alt+Space");
assert_eq!(cfg.hints.alphabet, crate::hint::DEFAULT_ALPHABET);
}
#[test]
fn empty_toml_is_all_defaults() {
let cfg: Config = toml::from_str("").unwrap();
assert_eq!(cfg, Config::default());
}
}