tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ dba855e446ca2bd4e7d146bdc168fb232cad15a4
widgets/calendar/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
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}