float-clock-wayland 0.2.0

Always-on-top floating desktop clock widget for Wayland compositors.
use crate::clock::Clock;
use crate::config::Config;
use crate::drag::DragController;
use crate::platform;
use crate::render::{parse_alignment, parse_font_weight, parse_hex, ClockRenderer, Style};
use crate::state::{load_window_state, save_window_state, WindowState};
use crate::window::LayerShellWindow;

use glib::clone;
use gtk::prelude::*;
use gtk::{CssProvider, DrawingArea, StyleContext};
use log::warn;
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;

#[allow(clippy::needless_pass_by_value, clippy::too_many_lines)]
pub fn run(app: &gtk::Application, config: Config) {
    let saved_state = load_window_state();
    let start_x = config.pos_x.unwrap_or(saved_state.x);
    let start_y = config.pos_y.unwrap_or(saved_state.y);
    let initial_pos = (start_x, start_y);

    let window_mover = Rc::new(LayerShellWindow::new(app, initial_pos, &config.layer));

    let use_absolute = match config.backend.to_lowercase().as_str() {
        "hyprland" => true,
        "generic" => false,
        _ => platform::is_hyprland(),
    };

    let drag_controller = Rc::new(RefCell::new(DragController::new(
        initial_pos,
        use_absolute,
    )));

    let style = Rc::new(Style {
        font_family: config.font_family.clone(),
        font_weight: parse_font_weight(&config.font_weight),
        size_pt: config.size,
        text_rgba: parse_hex(&config.color),
        border_rgba: parse_hex(&config.border_color),
        thickness: config.thickness,
        alignment: parse_alignment(&config.alignment),
    });

    let renderer = Rc::new(ClockRenderer::new());
    let clock = Rc::new(Clock::new(config.demo));
    let time_text = Rc::new(RefCell::new(String::new()));

    let drawing_area = DrawingArea::new();
    window_mover.window().add(&drawing_area);

    window_mover.window().add_events(
        gtk::gdk::EventMask::BUTTON_PRESS_MASK
            | gtk::gdk::EventMask::BUTTON_RELEASE_MASK
            | gtk::gdk::EventMask::BUTTON1_MOTION_MASK,
    );

    // Mouse Button Press Event
    window_mover.window().connect_button_press_event(clone!(
        @strong window_mover,
        @strong drag_controller => move |_, event| {
            if event.button() == 1 {
                let (x, y) = event.root();
                #[allow(clippy::cast_possible_truncation)]
                drag_controller.borrow_mut().press((x as i32, y as i32));
                gtk::glib::Propagation::Stop
            } else if event.button() == 3 {
                window_mover.close();
                gtk::glib::Propagation::Stop
            } else {
                gtk::glib::Propagation::Proceed
            }
        }
    ));

    // Mouse Button Release Event
    let save_pos = config.save_position;
    window_mover.window().connect_button_release_event(clone!(
        @strong drag_controller => move |_, event| {
            if event.button() == 1 {
                let final_margin = drag_controller.borrow_mut().release();
                if save_pos {
                    save_window_state(WindowState {
                        x: final_margin.0,
                        y: final_margin.1,
                    });
                }
                gtk::glib::Propagation::Stop
            } else {
                gtk::glib::Propagation::Proceed
            }
        }
    ));

    // Mouse Motion Notify Event
    window_mover.window().connect_motion_notify_event(clone!(
        @strong window_mover,
        @strong drag_controller => move |_, event| {
            let (x, y) = event.root();
            #[allow(clippy::cast_possible_truncation)]
            if let Some(new_margin) = drag_controller.borrow_mut().motion((x as i32, y as i32)) {
                window_mover.set_margin(new_margin);
                gtk::glib::Propagation::Stop
            } else {
                gtk::glib::Propagation::Proceed
            }
        }
    ));

    // Drawing Area Draw Event
    drawing_area.connect_draw(clone!(
        @strong time_text,
        @strong renderer,
        @strong style => move |_, cr| {
            let text = time_text.borrow();
            renderer.draw(cr, &text, &style);
            gtk::glib::Propagation::Proceed
        }
    ));

    // Unix Signal Handlers (SIGINT = 2, SIGTERM = 15) for graceful shutdown
    let handle_signal = clone!(
        @strong window_mover,
        @strong drag_controller => move || {
            let margin = drag_controller.borrow().current_margin();
            if save_pos {
                save_window_state(WindowState {
                    x: margin.0,
                    y: margin.1,
                });
            }
            window_mover.close();
            glib::ControlFlow::Break
        }
    );

    glib::unix_signal_add_local(2, handle_signal.clone());
    glib::unix_signal_add_local(15, handle_signal);

    // Initial update
    let area_clone = drawing_area.clone();
    let format = config.format.clone();
    update_clock(&area_clone, &time_text, &clock, &format, &style);

    // Phase-locked sub-second adaptive timer loop
    if !config.demo {
        schedule_timer_tick(
            area_clone,
            time_text,
            clock,
            format,
            style,
        );
    }

    apply_css();
    window_mover.window().show_all();
}

#[allow(clippy::needless_pass_by_value)]
fn schedule_timer_tick(
    area: DrawingArea,
    time_text: Rc<RefCell<String>>,
    clock: Rc<Clock>,
    format: String,
    style: Rc<Style>,
) {
    let delay_ms = clock.millis_until_next_tick(&format);
    glib::timeout_add_local(
        Duration::from_millis(delay_ms),
        clone!(
            @strong area,
            @strong time_text,
            @strong clock,
            @strong format,
            @strong style => move || {
                update_clock(&area, &time_text, &clock, &format, &style);
                schedule_timer_tick(
                    area.clone(),
                    time_text.clone(),
                    clock.clone(),
                    format.clone(),
                    style.clone(),
                );
                glib::ControlFlow::Break
            }
        ),
    );
}

fn update_clock(
    area: &DrawingArea,
    text_ref: &Rc<RefCell<String>>,
    clock: &Clock,
    format_str: &str,
    style: &Style,
) {
    let time_str = clock.now_formatted(format_str);
    if *text_ref.borrow() != time_str {
        text_ref.borrow_mut().clone_from(&time_str);

        let layout = area.create_pango_layout(Some(&time_str));
        let mut font_desc = pango::FontDescription::new();
        font_desc.set_family(&style.font_family);
        #[allow(clippy::cast_possible_wrap)]
        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);

        let (width, height) = layout.pixel_size();
        let padding = style.thickness * 2;
        area.set_size_request(width + padding, height + padding);
        area.queue_draw();
    }
}

fn apply_css() {
    let css = if let Ok(content) = std::fs::read_to_string("assets/style.css") {
        content
    } else {
        "window { background-color: rgba(0, 0, 0, 0.01); }".to_string()
    };

    let provider = CssProvider::new();
    if let Err(e) = provider.load_from_data(css.as_bytes()) {
        warn!("Failed to load CSS: {e}");
    }

    if let Some(screen) = gtk::gdk::Screen::default() {
        StyleContext::add_provider_for_screen(
            &screen,
            &provider,
            gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
        );
    }
}