float-clock-wayland 0.2.0

Always-on-top floating desktop clock widget for Wayland compositors.
use crate::state::write_atomic;
use clap::Parser;
use log::warn;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;

/// Application configuration parsed from TOML / CLI.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(default)]
pub struct Config {
    /// Font size in Pango points.
    pub size: u32,
    /// Clock text color (HEX code).
    pub color: String,
    /// Border outline color (HEX code).
    pub border_color: String,
    /// Outline thickness in pixels.
    pub thickness: i32,
    /// Manual X position offset.
    pub pos_x: Option<i32>,
    /// Manual Y position offset.
    pub pos_y: Option<i32>,
    /// Chrono format string.
    pub format: String,
    /// Font family string.
    pub font_family: String,
    /// Font weight ("normal", "bold", "heavy", etc.).
    pub font_weight: String,
    /// Text alignment ("center", "left", "right").
    pub alignment: String,
    /// Wayland Layer Shell layer ("top", "overlay", "bottom", "background").
    pub layer: String,
    /// Pointer coordinate backend ("auto", "hyprland", "generic").
    pub backend: String,
    /// Auto-save window position.
    pub save_position: bool,
    /// Demo clock mode.
    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,
        }
    }
}

/// Command-line arguments using clap.
#[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);
    }
}