float-clock-wayland 0.2.1

Always-on-top floating desktop clock widget for Wayland compositors.
use cairo::Context;
use pango::{Alignment, FontDescription, Weight};
use pangocairo::functions as pangocairo_fn;

#[derive(Debug, Clone, PartialEq)]
pub struct Style {
    pub font_family: String,
    pub font_weight: Weight,
    pub size_pt: u32,
    pub text_rgba: (f64, f64, f64, f64),
    pub border_rgba: (f64, f64, f64, f64),
    pub thickness: i32,
    pub alignment: Alignment,
}

pub fn parse_hex(hex: &str) -> (f64, f64, f64, f64) {
    let clean = hex.trim_start_matches('#');
    let normalized = match clean.len() {
        3 => {
            let mut s = String::with_capacity(8);
            for c in clean.chars() {
                s.push(c);
                s.push(c);
            }
            s.push_str("ff");
            s
        }
        4 => {
            let mut s = String::with_capacity(8);
            for c in clean.chars() {
                s.push(c);
                s.push(c);
            }
            s
        }
        6 => format!("{clean}ff"),
        8 => clean.to_string(),
        _ => "ffffffff".to_string(),
    };

    let r = f64::from(u8::from_str_radix(&normalized[0..2], 16).unwrap_or(255)) / 255.0;
    let g = f64::from(u8::from_str_radix(&normalized[2..4], 16).unwrap_or(255)) / 255.0;
    let b = f64::from(u8::from_str_radix(&normalized[4..6], 16).unwrap_or(255)) / 255.0;
    let a = f64::from(u8::from_str_radix(&normalized[6..8], 16).unwrap_or(255)) / 255.0;

    (r, g, b, a)
}

pub fn parse_font_weight(weight: &str) -> Weight {
    match weight.to_lowercase().as_str() {
        "thin" => Weight::Thin,
        "ultralight" | "ultra-light" => Weight::Ultralight,
        "light" => Weight::Light,
        "semilight" | "semi-light" => Weight::Semilight,
        "book" => Weight::Book,
        "normal" => Weight::Normal,
        "medium" => Weight::Medium,
        "semibold" | "semi-bold" => Weight::Semibold,
        "bold" => Weight::Bold,
        "ultrabold" | "ultra-bold" => Weight::Ultrabold,
        "heavy" | "black" => Weight::Heavy,
        "ultraheavy" | "ultra-heavy" => Weight::Ultraheavy,
        _ => Weight::Heavy,
    }
}

pub fn parse_alignment(align: &str) -> Alignment {
    match align.to_lowercase().as_str() {
        "left" => Alignment::Left,
        "right" => Alignment::Right,
        _ => Alignment::Center,
    }
}

pub struct ClockRenderer;

impl ClockRenderer {
    pub fn new() -> Self {
        Self
    }

    pub fn draw(&self, cr: &Context, text: &str, style: &Style) {
        if text.is_empty() {
            return;
        }

        let layout = pangocairo::create_layout(cr);
        layout.set_text(text);

        let mut font_desc = FontDescription::new();
        font_desc.set_family(&style.font_family);
        font_desc.set_size(style.size_pt as i32 * pango::SCALE);
        font_desc.set_weight(style.font_weight);
        layout.set_font_description(Some(&font_desc));
        layout.set_alignment(style.alignment);

        // Translate context origin by thickness to accommodate outline border
        cr.translate(f64::from(style.thickness), f64::from(style.thickness));

        // Build Pango vector path
        pangocairo_fn::layout_path(cr, &layout);

        // Draw thick outline border stroke
        if style.thickness > 0 {
            let (r, g, b, a) = style.border_rgba;
            cr.set_source_rgba(r, g, b, a);
            cr.set_line_width(f64::from(style.thickness) * 2.0);
            cr.set_line_join(cairo::LineJoin::Round);
            let _ = cr.stroke_preserve();
        }

        // Fill inner text
        let (r, g, b, a) = style.text_rgba;
        cr.set_source_rgba(r, g, b, a);
        let _ = cr.fill();
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_hex() {
        let (r, g, b, a) = parse_hex("#00b7ff");
        assert!((r - 0.0).abs() < 1e-4);
        assert!((g - (183.0 / 255.0)).abs() < 1e-4);
        assert!((b - 1.0).abs() < 1e-4);
        assert!((a - 1.0).abs() < 1e-4);
    }

    #[test]
    fn test_parse_font_weight() {
        assert_eq!(parse_font_weight("bold"), Weight::Bold);
        assert_eq!(parse_font_weight("light"), Weight::Light);
        assert_eq!(parse_font_weight("invalid"), Weight::Heavy);
    }

    #[test]
    fn test_parse_alignment() {
        assert_eq!(parse_alignment("left"), Alignment::Left);
        assert_eq!(parse_alignment("right"), Alignment::Right);
        assert_eq!(parse_alignment("center"), Alignment::Center);
    }
}