🔒 Repository is read-only – file editing is disabled.

luna/crates/libluna-widgets/src/calendar.rs main

266 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
//! `Calendar` — kalendarz miesięczny (GTK4): siatka tygodni + wybór dnia.
//!
//! Widget immediate: trzyma rok, miesiąc i wybrany dzień, liczy geometrię dni i obsługuje
//! trafienia. Nazwy dni tygodnia i tytuł miesiąca pochodzą z aplikacji (i18n), bo widgety
//! libLuny nie zależą od warstwy językowej.

use libluna_core::style::weight;
use libluna_core::{Point, Rect, Theme};

use crate::ctx::PaintCtx;

/// Kolumny tygodnia.
const COLS: usize = 7;
/// Stała liczba wierszy dni (miesiąc zajmuje do 6 tygodni) — stabilny układ.
const ROWS: usize = 6;

/// Kalendarz miesięczny.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Calendar {
    year: i32,
    month: u32,
    selected: u32,
}

impl Calendar {
    /// Kalendarz na dany miesiąc (`month` 1..=12), z wybranym dniem 1.
    pub fn new(year: i32, month: u32) -> Self {
        Self {
            year,
            month: month.clamp(1, 12),
            selected: 1,
        }
    }

    pub fn year(&self) -> i32 {
        self.year
    }

    pub fn month(&self) -> u32 {
        self.month
    }

    pub fn selected(&self) -> u32 {
        self.selected
    }

    /// Wybiera dzień (przycięty do zakresu miesiąca).
    pub fn set_selected(&mut self, day: u32) {
        self.selected = day.clamp(1, self.days_in_month());
    }

    /// Liczba dni w bieżącym miesiącu.
    pub fn days_in_month(&self) -> u32 {
        days_in_month(self.year, self.month)
    }

    /// Dzień tygodnia pierwszego dnia miesiąca (`0` = poniedziałek … `6` = niedziela).
    pub fn first_weekday(&self) -> u32 {
        let w = days_from_civil(self.year, self.month, 1);
        ((w + 4).rem_euclid(7) as u32 + 6) % 7
    }

    /// Przechodzi do poprzedniego miesiąca.
    pub fn prev_month(&mut self) {
        if self.month == 1 {
            self.month = 12;
            self.year -= 1;
        } else {
            self.month -= 1;
        }
        self.set_selected(self.selected);
    }

    /// Przechodzi do następnego miesiąca.
    pub fn next_month(&mut self) {
        if self.month == 12 {
            self.month = 1;
            self.year += 1;
        } else {
            self.month += 1;
        }
        self.set_selected(self.selected);
    }

    pub fn title_height(&self, theme: &Theme) -> f64 {
        theme.typography.body.line_height + theme.spacing.sm * 2.0
    }

    pub fn weekday_height(&self, theme: &Theme) -> f64 {
        theme.typography.caption.line_height + theme.spacing.xs
    }

    fn grid_rect(&self, rect: Rect, theme: &Theme) -> Rect {
        let top = self.title_height(theme) + self.weekday_height(theme);
        Rect::from_xywh(
            rect.x(),
            rect.y() + top,
            rect.width(),
            (rect.height() - top).max(0.0),
        )
    }

    /// Prostokąt komórki `(row, col)` w siatce dni.
    pub fn cell_rect(&self, rect: Rect, theme: &Theme, row: usize, col: usize) -> Rect {
        let g = self.grid_rect(rect, theme);
        let cw = g.width() / COLS as f64;
        let ch = g.height() / ROWS as f64;
        Rect::from_xywh(g.x() + col as f64 * cw, g.y() + row as f64 * ch, cw, ch)
    }

    /// Prostokąt dnia miesiąca.
    pub fn day_rect(&self, rect: Rect, theme: &Theme, day: u32) -> Rect {
        let idx = self.first_weekday() + day - 1;
        let row = (idx / COLS as u32) as usize;
        let col = (idx % COLS as u32) as usize;
        self.cell_rect(rect, theme, row.min(ROWS - 1), col)
    }

    /// Dzień pod punktem `p` (lub `None`).
    pub fn day_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<u32> {
        (1..=self.days_in_month()).find(|d| self.day_rect(rect, theme, *d).contains(p))
    }

    /// Rysuje kalendarz: tytuł, nagłówek dni tygodnia (`weekdays` = 7 napisów) i dni.
    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect, title: &str, weekdays: &[String]) {
        let theme = *ctx.theme;
        let body = theme.typography.body.size;
        let caption = theme.typography.caption.size;

        // Tytuł miesiąca.
        ctx.label_left(
            rect.x() + theme.spacing.sm,
            rect.y() + self.title_height(&theme) / 2.0,
            title,
            body,
            weight::MEDIUM,
            theme.colors.text,
        );

        // Nagłówek dni tygodnia.
        let g = self.grid_rect(rect, &theme);
        let wy = rect.y() + self.title_height(&theme) + self.weekday_height(&theme) / 2.0;
        let cw = g.width() / COLS as f64;
        for (i, name) in weekdays.iter().take(COLS).enumerate() {
            ctx.label_center(
                g.x() + (i as f64 + 0.5) * cw,
                wy,
                name,
                caption,
                weight::NORMAL,
                theme.colors.dim_text,
            );
        }

        // Dni.
        for day in 1..=self.days_in_month() {
            let cell = self.day_rect(rect, &theme, day);
            let c = cell.center();
            let selected = day == self.selected;
            let r = (cell.width().min(cell.height()) / 2.0 - 2.0).max(6.0);
            if selected {
                ctx.scene.fill_rounded_rect(
                    Rect::from_xywh(c.x - r, c.y - r, r * 2.0, r * 2.0),
                    r,
                    theme.colors.accent,
                );
            }
            let fg = if selected {
                theme.colors.accent_fg
            } else {
                theme.colors.text
            };
            ctx.label_center(c.x, c.y, &day.to_string(), caption, weight::NORMAL, fg);
        }
    }
}

fn is_leap(year: i32) -> bool {
    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}

fn days_in_month(year: i32, month: u32) -> u32 {
    match month {
        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
        4 | 6 | 9 | 11 => 30,
        2 => {
            if is_leap(year) {
                29
            } else {
                28
            }
        }
        _ => 30,
    }
}

/// Liczba dni od 1970-01-01 (algorytm Howarda Hinnanta). 1970-01-01 = 0 (czwartek).
fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
    let y = if m <= 2 { y - 1 } else { y } as i64;
    let m = m as i64;
    let d = d as i64;
    let era = if y >= 0 { y } else { y - 399 } / 400;
    let yoe = y - era * 400;
    let mp = if m > 2 { m - 3 } else { m + 9 };
    let doy = (153 * mp + 2) / 5 + d - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    era * 146097 + doe - 719468
}

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

    #[test]
    fn month_lengths_and_leap_years() {
        assert_eq!(days_in_month(2024, 2), 29);
        assert_eq!(days_in_month(2023, 2), 28);
        assert_eq!(days_in_month(2024, 4), 30);
        assert_eq!(days_in_month(2024, 1), 31);
    }

    #[test]
    fn first_weekday_is_monday_zero() {
        // 2024-01-01 to poniedziałek.
        assert_eq!(Calendar::new(2024, 1).first_weekday(), 0);
        // 2023-01-01 to niedziela.
        assert_eq!(Calendar::new(2023, 1).first_weekday(), 6);
    }

    #[test]
    fn month_navigation_wraps_years() {
        let mut c = Calendar::new(2024, 1);
        c.prev_month();
        assert_eq!((c.year(), c.month()), (2023, 12));
        c.next_month();
        assert_eq!((c.year(), c.month()), (2024, 1));
        let mut d = Calendar::new(2024, 12);
        d.next_month();
        assert_eq!((d.year(), d.month()), (2025, 1));
    }

    #[test]
    fn selected_day_is_clamped() {
        let mut c = Calendar::new(2023, 2);
        c.set_selected(31);
        assert_eq!(c.selected(), 28);
        c.set_selected(0);
        assert_eq!(c.selected(), 1);
    }

    #[test]
    fn day_cells_hit_testing() {
        let theme = Theme::light();
        let rect = Rect::from_xywh(0.0, 0.0, 350.0, 300.0);
        let c = Calendar::new(2024, 1);
        let jan1 = c.day_rect(rect, &theme, 1);
        assert_eq!(c.day_at(rect, &theme, jan1.center()), Some(1));
        // Punkt w wierszu tytułu nie trafia w żaden dzień.
        assert_eq!(c.day_at(rect, &theme, Point::new(5.0, 5.0)), None);
        // Dni mieszczą się w siatce.
        for day in 1..=c.days_in_month() {
            let r = c.day_rect(rect, &theme, day);
            assert!(r.bottom() <= rect.bottom() + 1e-6);
        }
    }
}