gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ dba855e446ca2bd4e7d146bdc168fb232cad15a4 widgets/calendar/src/lib.rs Rust · 483 siq'ikuna · 17.5 KB Ñawpa willay Pi imata llamkarqan ch'uya

refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel ccab39f14 · Sergio ·

1//! `llimphi-widget-calendar` — vista mensual del calendario.
2//!
3//! Una grilla 7 (columnas = días de la semana) × 6 (filas = semanas)
4//! mostrando los días del mes en foco, con header de mes/año + flechas de
5//! navegación, fila de iniciales de día, día seleccionado y día actual
6//! resaltados. El widget **no tiene estado propio**: el mes en foco, la
7//! selección y la fecha "hoy" viven en el `Model` del caller; el widget
8//! emite `Msg`s para navegar y para seleccionar.
9//!
10//! Base del **date-picker** (un `field` + un overlay que muestra este
11//! calendar) y útil por sí solo para agendas, planning, ERP. La lógica
12//! del calendario (días del mes, primer día de la semana, grilla 6×7) es
13//! pública y testeable: ver [`days_in_month`], [`first_weekday`],
14//! [`month_grid`].
15
16#![forbid(unsafe_code)]
17
18use chrono::{Datelike, NaiveDate, Weekday};
19use llimphi_ui::llimphi_layout::taffy::{
20    prelude::{length, percent, FlexDirection, Size, Style},
21    AlignItems, JustifyContent,
22};
23use llimphi_ui::llimphi_raster::peniko::Color;
24use llimphi_ui::View;
25use llimphi_theme::Theme;
26
27/// Día de la semana con el que arranca la grilla. La mayor parte del mundo
28/// usa `Monday`; EE. UU. usa `Sunday`.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum WeekStart {
31    Monday,
32    Sunday,
33}
34
35impl Default for WeekStart {
36    fn default() -> Self {
37        Self::Monday
38    }
39}
40
41impl WeekStart {
42    /// Posición (0..7) del `weekday` empezando por este `WeekStart`. Con
43    /// `Monday`: `Mon=0, Tue=1, …, Sun=6`. Con `Sunday`: `Sun=0, Mon=1,
44    /// …, Sat=6`.
45    pub fn index(self, w: Weekday) -> u32 {
46        let mon0 = w.num_days_from_monday();
47        match self {
48            Self::Monday => mon0,
49            // Domingo (mon0=6) pasa a 0, lunes (0) a 1, …
50            Self::Sunday => (mon0 + 1) % 7,
51        }
52    }
53
54    /// Iniciales de los siete días en el orden que dicta este `WeekStart`,
55    /// con `locale = "es"` (L/M/M/J/V/S/D para Monday-start). Suficiente
56    /// para v1; un mapa por `Theme.locale` se agrega cuando alguna app lo
57    /// pida.
58    pub fn weekday_initials_es(self) -> [&'static str; 7] {
59        match self {
60            Self::Monday => ["L", "M", "M", "J", "V", "S", "D"],
61            Self::Sunday => ["D", "L", "M", "M", "J", "V", "S"],
62        }
63    }
64}
65
66/// Cantidad de días del mes `month` (1..=12) del año `year`. Devuelve `0`
67/// si `month` está fuera de rango (defensa contra `u32` arbitrario; un
68/// caller correcto pasa 1..=12).
69pub fn days_in_month(year: i32, month: u32) -> u32 {
70    if !(1..=12).contains(&month) {
71        return 0;
72    }
73    // Trick estándar: primer día del mes siguiente menos un día. Año
74    // siguiente si month == 12. `from_ymd_opt` retorna `Some` siempre que
75    // (year, month, day) sean válidos — para day=1 con month 1..=12 lo es.
76    let (y2, m2) = if month == 12 {
77        (year + 1, 1)
78    } else {
79        (year, month + 1)
80    };
81    let first_next = NaiveDate::from_ymd_opt(y2, m2, 1).expect("primer día válido");
82    let first_this = NaiveDate::from_ymd_opt(year, month, 1).expect("primer día válido");
83    first_next.signed_duration_since(first_this).num_days() as u32
84}
85
86/// Día de la semana del **primer día** del mes (1 del mes). Sólo útil para
87/// quien quiera el `Weekday` crudo; la grilla usa [`first_weekday_index`].
88pub fn first_weekday(year: i32, month: u32) -> Option<Weekday> {
89    NaiveDate::from_ymd_opt(year, month, 1).map(|d| d.weekday())
90}
91
92/// Índice de columna (0..7) del primer día del mes según `WeekStart`. Es
93/// la cantidad de celdas vacías ANTES del 1 en la primera fila de la
94/// grilla. Devuelve `0` si el mes es inválido.
95pub fn first_weekday_index(year: i32, month: u32, start: WeekStart) -> u32 {
96    first_weekday(year, month).map(|w| start.index(w)).unwrap_or(0)
97}
98
99/// Grilla mensual de **6 filas × 7 columnas** con los días del mes. Las
100/// celdas que caen antes del primer día o después del último día del mes
101/// son `None`. Siempre seis filas: para meses cortos (28 días arrancando
102/// en domingo bajo Monday-start, p. ej. febrero 2021) la última fila queda
103/// vacía; mantener seis filas estables evita reflow al navegar entre
104/// meses (toda la grilla mide igual). Si `month` es inválido devuelve seis
105/// filas de siete `None`s.
106pub fn month_grid(year: i32, month: u32, start: WeekStart) -> [[Option<u32>; 7]; 6] {
107    let mut grid: [[Option<u32>; 7]; 6] = [[None; 7]; 6];
108    let dim = days_in_month(year, month);
109    if dim == 0 {
110        return grid;
111    }
112    let offset = first_weekday_index(year, month, start) as usize;
113    for day in 1..=dim {
114        let cell = offset + (day as usize) - 1;
115        let r = cell / 7;
116        let c = cell % 7;
117        if r < 6 {
118            grid[r][c] = Some(day);
119        }
120        // Si r == 6 (no debería pasar para meses estándar: 31+6=37<42),
121        // se ignora; la grilla de 6×7 = 42 celdas alcanza para el peor caso
122        // (31 días con offset = 6 → última celda 36, fila 5).
123    }
124    grid
125}
126
127/// Avanza `(year, month)` un mes adelante (`delta = +1`) o atrás (`-1`),
128/// wrappeando años. Helper para los botones `<`/`>` del header.
129pub fn shift_month(year: i32, month: u32, delta: i32) -> (i32, u32) {
130    let total = (year * 12 + month as i32 - 1) + delta;
131    let y = total.div_euclid(12);
132    let m = (total.rem_euclid(12) + 1) as u32;
133    (y, m)
134}
135
136/// Paleta del calendario.
137#[derive(Debug, Clone, Copy)]
138pub struct CalendarPalette {
139    /// Fondo del panel.
140    pub bg: Color,
141    /// Texto normal de los días del mes en foco.
142    pub fg: Color,
143    /// Texto de las iniciales de día / mes/año del header (atenuado).
144    pub fg_muted: Color,
145    /// Hover sobre una celda de día.
146    pub hover_bg: Color,
147    /// Fondo de la celda del día seleccionado.
148    pub selected_bg: Color,
149    /// Texto del día seleccionado (contraste contra `selected_bg`).
150    pub selected_fg: Color,
151    /// Color del borde de la celda "hoy" (sólo borde — el fondo sigue al
152    /// hover/selected normal).
153    pub today_border: Color,
154}
155
156impl CalendarPalette {
157    pub fn from_theme(t: &Theme) -> Self {
158        Self {
159            bg: t.bg_panel,
160            fg: t.fg_text,
161            fg_muted: t.fg_muted,
162            hover_bg: t.bg_row_hover,
163            selected_bg: t.accent,
164            // El texto sobre `accent` se elige por contraste — el theme no
165            // expone un `fg_on_accent` dedicado, así que tomamos `bg_app`
166            // (oscuro en dark theme, claro en light theme) que es el
167            // inverso natural del `fg_text`.
168            selected_fg: t.bg_app,
169            today_border: t.accent,
170        }
171    }
172}
173
174impl Default for CalendarPalette {
175    fn default() -> Self {
176        Self::from_theme(&Theme::dark())
177    }
178}
179
180/// Especificación del calendario para una vista mensual.
181pub struct CalendarSpec<Msg> {
182    /// Año del mes en foco.
183    pub view_year: i32,
184    /// Mes en foco (1..=12).
185    pub view_month: u32,
186    /// Día seleccionado (opcional; si cae en otro mes no se resalta).
187    pub selected: Option<NaiveDate>,
188    /// Fecha "hoy" (opcional; si cae en otro mes no se resalta). El caller
189    /// la inyecta — el widget no toca el reloj para mantenerse puro y
190    /// testeable.
191    pub today: Option<NaiveDate>,
192    /// Primer día de la semana en la grilla.
193    pub week_start: WeekStart,
194    pub palette: CalendarPalette,
195    /// `Msg` para "seleccioné el día X (NaiveDate del mes en foco)".
196    pub on_select: std::sync::Arc<dyn Fn(NaiveDate) -> Msg + Send + Sync>,
197    /// `Msg` para "muévete al mes (year, month)" — disparado por `<` / `>`.
198    pub on_view_change: std::sync::Arc<dyn Fn(i32, u32) -> Msg + Send + Sync>,
199}
200
201/// Nombre del mes en español (1..=12). Para v1; un map por `Theme.locale`
202/// se agrega cuando alguna app lo pida.
203fn month_name_es(m: u32) -> &'static str {
204    match m {
205        1 => "enero",
206        2 => "febrero",
207        3 => "marzo",
208        4 => "abril",
209        5 => "mayo",
210        6 => "junio",
211        7 => "julio",
212        8 => "agosto",
213        9 => "septiembre",
214        10 => "octubre",
215        11 => "noviembre",
216        12 => "diciembre",
217        _ => "—",
218    }
219}
220
221const HEADER_H: f32 = 32.0;
222const ROW_H: f32 = 32.0;
223const CELL_W: f32 = 32.0;
224const PAD: f32 = 8.0;
225
226/// Vista del calendario. Devuelve un `View<Msg>` autocontenido (panel +
227/// header + week labels + grilla 6×7), de ancho fijo `7 * CELL_W + 2 * PAD`.
228pub fn calendar_view<Msg>(spec: CalendarSpec<Msg>) -> View<Msg>
229where
230    Msg: Clone + 'static,
231{
232    let CalendarSpec {
233        view_year,
234        view_month,
235        selected,
236        today,
237        week_start,
238        palette,
239        on_select,
240        on_view_change,
241    } = spec;
242
243    // Header: < Mes Año >
244    let prev = {
245        let f = on_view_change.clone();
246        let (py, pm) = shift_month(view_year, view_month, -1);
247        View::<Msg>::new(Style {
248            size: Size { width: length(CELL_W), height: length(HEADER_H) },
249            align_items: Some(AlignItems::Center),
250            justify_content: Some(JustifyContent::Center),
251            ..Default::default()
252        })
253        .text("‹", 18.0, palette.fg_muted)
254        .hover_fill(palette.hover_bg)
255        .radius(6.0)
256        .cursor(llimphi_ui::Cursor::Pointer)
257        .on_click_at({
258            let f = f.clone();
259            move |_, _, _, _| Some(f(py, pm))
260        })
261    };
262    let next = {
263        let f = on_view_change.clone();
264        let (ny, nm) = shift_month(view_year, view_month, 1);
265        View::<Msg>::new(Style {
266            size: Size { width: length(CELL_W), height: length(HEADER_H) },
267            align_items: Some(AlignItems::Center),
268            justify_content: Some(JustifyContent::Center),
269            ..Default::default()
270        })
271        .text("›", 18.0, palette.fg_muted)
272        .hover_fill(palette.hover_bg)
273        .radius(6.0)
274        .cursor(llimphi_ui::Cursor::Pointer)
275        .on_click_at(move |_, _, _, _| Some(f(ny, nm)))
276    };
277    let title = format!("{} {}", month_name_es(view_month), view_year);
278    let title_box = View::<Msg>::new(Style {
279        size: Size { width: percent(1.0), height: length(HEADER_H) },
280        flex_grow: 1.0,
281        align_items: Some(AlignItems::Center),
282        justify_content: Some(JustifyContent::Center),
283        ..Default::default()
284    })
285    .text(title, 14.0, palette.fg);
286    let header = View::<Msg>::new(Style {
287        flex_direction: FlexDirection::Row,
288        align_items: Some(AlignItems::Center),
289        size: Size { width: percent(1.0), height: length(HEADER_H) },
290        ..Default::default()
291    })
292    .children(vec![prev, title_box, next]);
293
294    // Week labels (L M M J V S D o D L M M J V S).
295    let mut week_cols: Vec<View<Msg>> = Vec::with_capacity(7);
296    for label in week_start.weekday_initials_es() {
297        week_cols.push(
298            View::<Msg>::new(Style {
299                size: Size { width: length(CELL_W), height: length(ROW_H * 0.75) },
300                align_items: Some(AlignItems::Center),
301                justify_content: Some(JustifyContent::Center),
302                ..Default::default()
303            })
304            .text(label, 11.0, palette.fg_muted),
305        );
306    }
307    let week_row = View::<Msg>::new(Style {
308        flex_direction: FlexDirection::Row,
309        size: Size {
310            width: length(CELL_W * 7.0),
311            height: length(ROW_H * 0.75),
312        },
313        ..Default::default()
314    })
315    .children(week_cols);
316
317    // Grilla 6×7.
318    let grid = month_grid(view_year, view_month, week_start);
319    let mut grid_rows: Vec<View<Msg>> = Vec::with_capacity(6);
320    for row in grid.iter() {
321        let mut cells: Vec<View<Msg>> = Vec::with_capacity(7);
322        for cell in row.iter() {
323            let view: View<Msg> = match *cell {
324                None => View::<Msg>::new(Style {
325                    size: Size { width: length(CELL_W), height: length(ROW_H) },
326                    ..Default::default()
327                }),
328                Some(day) => {
329                    let date = NaiveDate::from_ymd_opt(view_year, view_month, day)
330                        .expect("día válido del mes en foco");
331                    let is_sel = selected == Some(date);
332                    let is_today = today == Some(date);
333                    let fg = if is_sel { palette.selected_fg } else { palette.fg };
334                    let mut v = View::<Msg>::new(Style {
335                        size: Size { width: length(CELL_W), height: length(ROW_H) },
336                        align_items: Some(AlignItems::Center),
337                        justify_content: Some(JustifyContent::Center),
338                        ..Default::default()
339                    })
340                    .text(day.to_string(), 13.0, fg)
341                    .radius(6.0)
342                    .cursor(llimphi_ui::Cursor::Pointer);
343                    if is_sel {
344                        v = v.fill(palette.selected_bg);
345                    } else {
346                        v = v.hover_fill(palette.hover_bg);
347                    }
348                    if is_today {
349                        v = v.border(1.0, palette.today_border);
350                    }
351                    let f = on_select.clone();
352                    v = v.on_click_at(move |_, _, _, _| Some(f(date)));
353                    v
354                }
355            };
356            cells.push(view);
357        }
358        grid_rows.push(
359            View::<Msg>::new(Style {
360                flex_direction: FlexDirection::Row,
361                size: Size { width: length(CELL_W * 7.0), height: length(ROW_H) },
362                ..Default::default()
363            })
364            .children(cells),
365        );
366    }
367    let grid_box = View::<Msg>::new(Style {
368        flex_direction: FlexDirection::Column,
369        size: Size {
370            width: length(CELL_W * 7.0),
371            height: length(ROW_H * 6.0),
372        },
373        ..Default::default()
374    })
375    .children(grid_rows);
376
377    let total_w = CELL_W * 7.0 + PAD * 2.0;
378    let total_h = HEADER_H + ROW_H * 0.75 + ROW_H * 6.0 + PAD * 2.0;
379    View::<Msg>::new(Style {
380        flex_direction: FlexDirection::Column,
381        size: Size { width: length(total_w), height: length(total_h) },
382        padding: llimphi_ui::llimphi_layout::taffy::Rect {
383            top: length(PAD),
384            bottom: length(PAD),
385            left: length(PAD),
386            right: length(PAD),
387        },
388        ..Default::default()
389    })
390    .fill(palette.bg)
391    .radius(8.0)
392    .children(vec![header, week_row, grid_box])
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn days_in_month_meses_estandar() {
401        assert_eq!(days_in_month(2026, 1), 31);
402        assert_eq!(days_in_month(2026, 2), 28);
403        assert_eq!(days_in_month(2024, 2), 29); // bisiesto
404        assert_eq!(days_in_month(2026, 4), 30);
405        assert_eq!(days_in_month(2026, 12), 31);
406        // Fuera de rango → 0.
407        assert_eq!(days_in_month(2026, 0), 0);
408        assert_eq!(days_in_month(2026, 13), 0);
409    }
410
411    #[test]
412    fn first_weekday_index_monday_y_sunday_start() {
413        // 1 de enero 2026 cae JUEVES.
414        // Con Monday-start: Mon=0, Tue=1, Wed=2, Thu=3 → index = 3.
415        assert_eq!(first_weekday_index(2026, 1, WeekStart::Monday), 3);
416        // Con Sunday-start: Sun=0, Mon=1, ..., Thu=4.
417        assert_eq!(first_weekday_index(2026, 1, WeekStart::Sunday), 4);
418        // 1 de febrero 2026 cae DOMINGO.
419        // Monday-start: Sun=6.
420        assert_eq!(first_weekday_index(2026, 2, WeekStart::Monday), 6);
421        // Sunday-start: Sun=0.
422        assert_eq!(first_weekday_index(2026, 2, WeekStart::Sunday), 0);
423    }
424
425    #[test]
426    fn month_grid_enero_2026_monday_start() {
427        // Enero 2026: 31 días, primer día jueves (index 3 con Monday-start).
428        let g = month_grid(2026, 1, WeekStart::Monday);
429        // Tres celdas vacías al inicio.
430        assert_eq!(g[0][0], None);
431        assert_eq!(g[0][1], None);
432        assert_eq!(g[0][2], None);
433        assert_eq!(g[0][3], Some(1)); // jueves 1
434        assert_eq!(g[0][6], Some(4)); // domingo 4
435        assert_eq!(g[1][0], Some(5)); // lunes 5
436        // Último día: 31. (31 + 3 - 1) / 7 = 33/7 = 4, % 7 = 5 ⇒ fila 4, col 5.
437        assert_eq!(g[4][5], Some(31));
438        // Fila 5 (sexta) entera vacía.
439        for c in g[5].iter() {
440            assert_eq!(*c, None);
441        }
442    }
443
444    #[test]
445    fn month_grid_febrero_2026_28_dias_domingo_inicio() {
446        // Febrero 2026: 28 días, primer día domingo (index 6 con Monday-start).
447        let g = month_grid(2026, 2, WeekStart::Monday);
448        // Primera fila: seis vacías + Some(1) en col 6 (domingo).
449        for c in 0..6 {
450            assert_eq!(g[0][c], None);
451        }
452        assert_eq!(g[0][6], Some(1));
453        assert_eq!(g[1][0], Some(2)); // lunes 2
454        // 28: (28+6-1)/7 = 33/7 = 4, %7 = 5 ⇒ fila 4 col 5.
455        assert_eq!(g[4][5], Some(28));
456        // Fila 5 entera vacía.
457        for c in g[5].iter() {
458            assert_eq!(*c, None);
459        }
460    }
461
462    #[test]
463    fn shift_month_wrappea_anios() {
464        // Enero → diciembre del año previo.
465        assert_eq!(shift_month(2026, 1, -1), (2025, 12));
466        // Diciembre → enero del año siguiente.
467        assert_eq!(shift_month(2026, 12, 1), (2027, 1));
468        // Salto de varios meses.
469        assert_eq!(shift_month(2026, 6, -8), (2025, 10));
470        assert_eq!(shift_month(2026, 6, 18), (2027, 12));
471    }
472
473    #[test]
474    fn week_start_index_consistente() {
475        // Monday-start.
476        assert_eq!(WeekStart::Monday.index(Weekday::Mon), 0);
477        assert_eq!(WeekStart::Monday.index(Weekday::Sun), 6);
478        // Sunday-start.
479        assert_eq!(WeekStart::Sunday.index(Weekday::Sun), 0);
480        assert_eq!(WeekStart::Sunday.index(Weekday::Mon), 1);
481        assert_eq!(WeekStart::Sunday.index(Weekday::Sat), 6);
482    }
483}