use crate::state::write_atomic;
use clap::Parser;
use log::warn;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(default)]
pub struct Config {
pub size: u32,
pub color: String,
pub border_color: String,
pub thickness: i32,
pub pos_x: Option<i32>,
pub pos_y: Option<i32>,
pub format: String,
pub font_family: String,
pub font_weight: String,
pub alignment: String,
pub layer: String,
pub backend: String,
pub save_position: bool,
pub demo: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
size: 11,
color: "#00b7ff".to_string(),
border_color: "#000000".to_string(),
thickness: 3,
pos_x: None,
pos_y: None,
format: "%H:%M:%S\n%d/%m/%Y".to_string(),
font_family: "JetBrains Mono, monospace, sans-serif".to_string(),
font_weight: "heavy".to_string(),
alignment: "center".to_string(),
layer: "top".to_string(),
backend: "auto".to_string(),
save_position: true,
demo: false,
}
}
}
#[derive(Parser, Debug, Clone, Default)]
#[command(author, version, about = "Floating Clock widget for Wayland", long_about = None)]
pub struct Args {
#[arg(short, long, help = "Font size in Pango points")]
pub size: Option<u32>,
#[arg(short = 'c', long, help = "Clock text color (HEX code, e.g. #00b7ff)")]
pub color: Option<String>,
#[arg(short = 'b', long, help = "Text outline border color (HEX code, e.g. #000000)")]
pub border_color: Option<String>,
#[arg(short = 't', long, help = "Text outline thickness in pixels")]
pub thickness: Option<i32>,
#[arg(short = 'x', long, help = "Manual X margin offset (ignores position file)")]
pub pos_x: Option<i32>,
#[arg(short = 'y', long, help = "Manual Y margin offset (ignores position file)")]
pub pos_y: Option<i32>,
#[arg(short = 'f', long, help = "Date & time format string (chrono layout)")]
pub format: Option<String>,
#[arg(long, help = "Font family name (e.g. 'JetBrains Mono, sans-serif')")]
pub font_family: Option<String>,
#[arg(long, help = "Font weight (e.g. 'heavy', 'bold', 'normal')")]
pub font_weight: Option<String>,
#[arg(long, help = "Text alignment ('center', 'left', 'right')")]
pub alignment: Option<String>,
#[arg(long, help = "Wayland layer shell depth ('top', 'overlay', 'bottom', 'background')")]
pub layer: Option<String>,
#[arg(long, help = "Compositor pointer tracking backend ('auto', 'hyprland', 'generic')")]
pub backend: Option<String>,
#[arg(long, help = "Disable persistence of window position")]
pub no_save_position: bool,
#[arg(long, help = "Show a static demo time (09:41:00 on Jan 9, 2007)")]
pub demo: bool,
}
pub fn get_config_dir() -> PathBuf {
std::env::var("XDG_CONFIG_HOME").map_or_else(|_| {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".config")
}, PathBuf::from)
.join("float-clock-wayland")
}
pub fn validate_config(mut config: Config) -> Config {
if config.size < 4 {
warn!("Configured size {} is too small, clamping to 4", config.size);
config.size = 4;
} else if config.size > 250 {
warn!("Configured size {} is too large, clamping to 250", config.size);
config.size = 250;
}
if config.thickness < 0 {
warn!("Configured thickness {} is negative, clamping to 0", config.thickness);
config.thickness = 0;
} else if config.thickness > 50 {
warn!("Configured thickness {} is too large, clamping to 50", config.thickness);
config.thickness = 50;
}
config
}
pub fn merge_config_and_args(mut config: Config, args: Args) -> Config {
if let Some(s) = args.size { config.size = s; }
if let Some(c) = args.color { config.color = c; }
if let Some(bc) = args.border_color { config.border_color = bc; }
if let Some(t) = args.thickness { config.thickness = t; }
if args.pos_x.is_some() { config.pos_x = args.pos_x; }
if args.pos_y.is_some() { config.pos_y = args.pos_y; }
if let Some(f) = args.format { config.format = f; }
if let Some(ff) = args.font_family { config.font_family = ff; }
if let Some(fw) = args.font_weight { config.font_weight = fw; }
if let Some(a) = args.alignment { config.alignment = a; }
if let Some(l) = args.layer { config.layer = l; }
if let Some(b) = args.backend { config.backend = b; }
if args.no_save_position { config.save_position = false; }
if args.demo { config.demo = true; }
config
}
pub fn load_or_create_config() -> Config {
let config_dir = get_config_dir();
if let Err(e) = fs::create_dir_all(&config_dir) {
warn!("Could not create config directory {config_dir:?}: {e}");
}
let config_path = config_dir.join("config.toml");
if config_path.exists() {
match fs::read_to_string(&config_path) {
Ok(contents) => match toml::from_str::<Config>(&contents) {
Ok(config) => validate_config(config),
Err(e) => {
warn!("Could not parse config {config_path:?} ({e}), using defaults");
Config::default()
}
},
Err(e) => {
warn!("Could not read config {config_path:?} ({e}), using defaults");
Config::default()
}
}
} else {
let default_config = Config::default();
if let Ok(toml_str) = toml::to_string_pretty(&default_config) {
let mut file_content = String::new();
file_content.push_str("# float-clock-wayland configuration file\n\n");
file_content.push_str(&toml_str);
if let Err(e) = write_atomic(&config_path, &file_content) {
warn!("Could not write default config file {config_path:?}: {e}");
}
}
default_config
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_partial_toml_deserialization_uses_defaults() {
let partial_toml = r##"
size = 15
color = "#ff0000"
"##;
let config: Config = toml::from_str(partial_toml).unwrap();
assert_eq!(config.size, 15);
assert_eq!(config.color, "#ff0000");
assert_eq!(config.alignment, "center");
assert_eq!(config.layer, "top");
}
#[test]
fn test_cli_overrides_defaults() {
let default_config = Config::default();
let args = Args {
size: Some(20),
color: Some("#ff0000".to_string()),
alignment: Some("left".to_string()),
demo: true,
no_save_position: true,
..Default::default()
};
let merged = merge_config_and_args(default_config, args);
assert_eq!(merged.size, 20);
assert_eq!(merged.color, "#ff0000");
assert_eq!(merged.alignment, "left");
assert!(merged.demo);
assert!(!merged.save_position);
}
}