tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
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
18
19// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
20// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
21// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
22#![no_std]
23
24extern crate alloc;
25
26use alloc::vec::Vec;
27use alloc::vec;
28use alloc::format;
29use alloc::string::ToString;
30use chrono::{Datelike, NaiveDate, Weekday};
31use llimphi_compositor::llimphi_layout::taffy::{
32 prelude::{length, percent, FlexDirection, Size, Style},
33 AlignItems, JustifyContent,
34};
35use llimphi_compositor::peniko::Color;
36use llimphi_compositor::View;
37use llimphi_theme::Theme;
38
39/// Día de la semana con el que arranca la grilla. La mayor parte del mundo
40/// usa `Monday`; EE. UU. usa `Sunday`.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum WeekStart {
43 Monday,
44 Sunday,
45}
46
47impl Default for WeekStart {
48 fn default() -> Self {
49 Self::Monday
50 }
51}
52
53impl WeekStart {
54 /// Posición (0..7) del `weekday` empezando por este `WeekStart`. Con
55 /// `Monday`: `Mon=0, Tue=1, …, Sun=6`. Con `Sunday`: `Sun=0, Mon=1,
56 /// …, Sat=6`.
57 pub fn index(self, w: Weekday) -> u32 {
58 let mon0 = w.num_days_from_monday();
59 match self {
60 Self::Monday => mon0,
61 // Domingo (mon0=6) pasa a 0, lunes (0) a 1, …
62 Self::Sunday => (mon0 + 1) % 7,
63 }
64 }
65
66 /// Iniciales de los siete días en el orden que dicta este `WeekStart`,
67 /// con `locale = "es"` (L/M/M/J/V/S/D para Monday-start). Suficiente
68 /// para v1; un mapa por `Theme.locale` se agrega cuando alguna app lo
69 /// pida.
70 pub fn weekday_initials_es(self) -> [&'static str; 7] {
71 match self {
72 Self::Monday => ["L", "M", "M", "J", "V", "S", "D"],
73 Self::Sunday => ["D", "L", "M", "M", "J", "V", "S"],
74 }
75 }
76}
77
78/// Cantidad de días del mes `month` (1..=12) del año `year`. Devuelve `0`
79/// si `month` está fuera de rango (defensa contra `u32` arbitrario; un
80/// caller correcto pasa 1..=12).
81pub fn days_in_month(year: i32, month: u32) -> u32 {
82 if !(1..=12).contains(&month) {
83 return 0;
84 }
85 // Trick estándar: primer día del mes siguiente menos un día. Año
86 // siguiente si month == 12. `from_ymd_opt` retorna `Some` siempre que
87 // (year, month, day) sean válidos — para day=1 con month 1..=12 lo es.
88 let (y2, m2) = if month == 12 {
89 (year + 1, 1)
90 } else {
91 (year, month + 1)
92 };
93 let first_next = NaiveDate::from_ymd_opt(y2, m2, 1).expect("primer día válido");
94 let first_this = NaiveDate::from_ymd_opt(year, month, 1).expect("primer día válido");
95 first_next.signed_duration_since(first_this).num_days() as u32
96}
97
98/// Día de la semana del **primer día** del mes (1 del mes). Sólo útil para
99/// quien quiera el `Weekday` crudo; la grilla usa [`first_weekday_index`].
100pub fn first_weekday(year: i32, month: u32) -> Option<Weekday> {
101 NaiveDate::from_ymd_opt(year, month, 1).map(|d| d.weekday())
102}
103
104/// Índice de columna (0..7) del primer día del mes según `WeekStart`. Es
105/// la cantidad de celdas vacías ANTES del 1 en la primera fila de la
106/// grilla. Devuelve `0` si el mes es inválido.
107pub fn first_weekday_index(year: i32, month: u32, start: WeekStart) -> u32 {
108 first_weekday(year, month).map(|w| start.index(w)).unwrap_or(0)
109}
110
111/// Grilla mensual de **6 filas × 7 columnas** con los días del mes. Las
112/// celdas que caen antes del primer día o después del último día del mes
113/// son `None`. Siempre seis filas: para meses cortos (28 días arrancando
114/// en domingo bajo Monday-start, p. ej. febrero 2021) la última fila queda
115/// vacía; mantener seis filas estables evita reflow al navegar entre
116/// meses (toda la grilla mide igual). Si `month` es inválido devuelve seis
117/// filas de siete `None`s.
118pub fn month_grid(year: i32, month: u32, start: WeekStart) -> [[Option<u32>; 7]; 6] {
119 let mut grid: [[Option<u32>; 7]; 6] = [[None; 7]; 6];
120 let dim = days_in_month(year, month);
121 if dim == 0 {
122 return grid;
123 }
124 let offset = first_weekday_index(year, month, start) as usize;
125 for day in 1..=dim {
126 let cell = offset + (day as usize) - 1;
127 let r = cell / 7;
128 let c = cell % 7;
129 if r < 6 {
130 grid[r][c] = Some(day);
131 }
132 // Si r == 6 (no debería pasar para meses estándar: 31+6=37<42),
133 // se ignora; la grilla de 6×7 = 42 celdas alcanza para el peor caso
134 // (31 días con offset = 6 → última celda 36, fila 5).
135 }
136 grid
137}
138
139/// Avanza `(year, month)` un mes adelante (`delta = +1`) o atrás (`-1`),
140/// wrappeando años. Helper para los botones `<`/`>` del header.
141pub fn shift_month(year: i32, month: u32, delta: i32) -> (i32, u32) {
142 let total = (year * 12 + month as i32 - 1) + delta;
143 let y = total.div_euclid(12);
144 let m = (total.rem_euclid(12) + 1) as u32;
145 (y, m)
146}
147
148/// Un movimiento de teclado sobre el calendario, tal como lo mapea un
149/// `<input type=date>` / DatePicker nativo.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum CalMove {
152 /// ← / → : un día.
153 Day(i32),
154 /// ↑ / ↓ : una semana (∓7 días).
155 Week(i32),
156 /// PageUp / PageDown : un mes (calendario, preserva el día si existe).
157 Month(i32),
158 /// Ctrl+PageUp / Ctrl+PageDown : un **año**, con la misma regla de
159 /// preservar el día que `Month` (29-feb + 1 año → 28-feb).
160 Year(i32),
161 /// Home / End : primer / último día del mes.
162 MonthEdge(bool),
163}
164
165/// Aplica un [`CalMove`] a la fecha enfocada y devuelve la nueva fecha —
166/// la fuente de verdad de la navegación por teclado (el host guarda la
167/// fecha en foco y dispatcha su `Msg` con este resultado). `Day`/`Week`
168/// usan aritmética de días real (cruzan meses y años); `Month` preserva
169/// el día clampeándolo al último del mes destino (31-ene + 1 mes → 28/29-feb).
170pub fn nav_date(date: NaiveDate, mov: CalMove) -> NaiveDate {
171 match mov {
172 CalMove::Day(n) => date + chrono::Duration::days(n as i64),
173 CalMove::Week(n) => date + chrono::Duration::days((n * 7) as i64),
174 CalMove::Month(n) => {
175 let (y, m) = shift_month(date.year(), date.month(), n);
176 let last = days_in_month(y, m);
177 let day = date.day().min(last);
178 NaiveDate::from_ymd_opt(y, m, day).expect("día clampeado al mes es válido")
179 }
180 // Un año son doce meses: así el clampeo del día sale de la misma regla
181 // (29-feb de un bisiesto + 1 año → 28-feb) en vez de tener la suya.
182 CalMove::Year(n) => nav_date(date, CalMove::Month(n.saturating_mul(12))),
183 CalMove::MonthEdge(to_end) => {
184 let day = if to_end {
185 days_in_month(date.year(), date.month())
186 } else {
187 1
188 };
189 NaiveDate::from_ymd_opt(date.year(), date.month(), day)
190 .expect("borde de mes válido")
191 }
192 }
193}
194
195/// Límites de fecha seleccionable: `[min, max]`, ambos **inclusive**, y un
196/// extremo en `None` es "sin límite por ese lado". Es lo que hace un
197/// `<input type=date min max>` y lo que pide cualquier reserva ("no antes de
198/// hoy") o cierre contable ("no después del último día del ejercicio").
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
200pub struct Limites {
201 pub min: Option<NaiveDate>,
202 pub max: Option<NaiveDate>,
203}
204
205impl Limites {
206 /// Sólo tope inferior.
207 pub fn desde(min: NaiveDate) -> Self {
208 Self { min: Some(min), max: None }
209 }
210
211 /// Sólo tope superior.
212 pub fn hasta(max: NaiveDate) -> Self {
213 Self { min: None, max: Some(max) }
214 }
215
216 /// Ambos topes.
217 pub fn entre(min: NaiveDate, max: NaiveDate) -> Self {
218 Self { min: Some(min), max: Some(max) }
219 }
220
221 /// ¿`d` es seleccionable? Sin límites, todo lo es.
222 pub fn permite(&self, d: NaiveDate) -> bool {
223 self.min.is_none_or(|m| d >= m) && self.max.is_none_or(|m| d <= m)
224 }
225
226 /// La fecha más cercana a `d` que sí es seleccionable.
227 pub fn clamp(&self, d: NaiveDate) -> NaiveDate {
228 let d = self.min.map_or(d, |m| d.max(m));
229 self.max.map_or(d, |m| d.min(m))
230 }
231
232 /// ¿El mes `(year, month)` tiene **algún** día seleccionable? Es lo que
233 /// decide si la flecha de navegación se apaga: un mes entero fuera de
234 /// límites no es un destino al que tenga sentido llevar a nadie.
235 pub fn mes_habil(&self, year: i32, month: u32) -> bool {
236 let dim = days_in_month(year, month);
237 if dim == 0 {
238 return false;
239 }
240 let primero = NaiveDate::from_ymd_opt(year, month, 1).expect("primer día válido");
241 let ultimo = NaiveDate::from_ymd_opt(year, month, dim).expect("último día válido");
242 // El mes es hábil si su tramo [primero, último] se cruza con [min, max].
243 self.min.is_none_or(|m| ultimo >= m) && self.max.is_none_or(|m| primero <= m)
244 }
245}
246
247/// Traduce una tecla al [`CalMove`] que le corresponde, con el mapa que usa un
248/// `<input type=date>` nativo. Devuelve `None` para todo lo demás — el host
249/// pasa el evento y actúa sólo si sale `Some`.
250///
251/// Existe para que el mapa **no lo reinvente cada host**: el enum documentaba
252/// las teclas en prosa y cada caller las cableaba a mano, que es exactamente
253/// cómo dos apps terminan navegando distinto el mismo widget.
254pub fn cal_move_desde_tecla(ev: &llimphi_compositor::KeyEvent) -> Option<CalMove> {
255 use llimphi_compositor::{Key, KeyState, NamedKey};
256 if ev.state != KeyState::Pressed {
257 return None;
258 }
259 let Key::Named(k) = ev.key else { return None };
260 // Ctrl convierte el salto de mes en salto de año, como en los pickers
261 // nativos; el resto de los modificadores no cambia el movimiento.
262 let anual = ev.modifiers.ctrl;
263 Some(match k {
264 NamedKey::ArrowLeft => CalMove::Day(-1),
265 NamedKey::ArrowRight => CalMove::Day(1),
266 NamedKey::ArrowUp => CalMove::Week(-1),
267 NamedKey::ArrowDown => CalMove::Week(1),
268 NamedKey::PageUp if anual => CalMove::Year(-1),
269 NamedKey::PageDown if anual => CalMove::Year(1),
270 NamedKey::PageUp => CalMove::Month(-1),
271 NamedKey::PageDown => CalMove::Month(1),
272 NamedKey::Home => CalMove::MonthEdge(false),
273 NamedKey::End => CalMove::MonthEdge(true),
274 _ => return None,
275 })
276}
277
278/// [`nav_date`] respetando [`Limites`]: si el movimiento se pasa del borde,
279/// aterriza **en el borde** en vez de irse afuera. Es la regla de un date-picker
280/// nativo — la tecla nunca queda muerta, pero tampoco te saca del rango.
281pub fn nav_date_limitada(date: NaiveDate, mov: CalMove, lim: &Limites) -> NaiveDate {
282 lim.clamp(nav_date(date, mov))
283}
284
285/// Selección de un **rango** de fechas: llegada/salida, desde/hasta de un
286/// reporte, período de un contrato.
287///
288/// El widget no guarda estado: el host tiene este valor en su `Model` y lo
289/// avanza con [`RangoSeleccion::click`] cada vez que llega un día elegido.
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
291pub struct RangoSeleccion {
292 pub inicio: Option<NaiveDate>,
293 pub fin: Option<NaiveDate>,
294}
295
296impl RangoSeleccion {
297 /// Rango de un solo día.
298 pub fn dia(d: NaiveDate) -> Self {
299 Self { inicio: Some(d), fin: Some(d) }
300 }
301
302 /// Máquina de estados de los clicks, en tres tiempos:
303 ///
304 /// 1. rango vacío → `d` queda como **inicio**, a la espera del otro extremo;
305 /// 2. medio rango → se cierra con `d`, **ordenado** (clickear antes del
306 /// inicio no rompe nada: pasa a ser el inicio y el viejo inicio el fin,
307 /// que es lo que espera cualquiera que "se corrige hacia atrás");
308 /// 3. rango completo → empieza uno nuevo desde `d`.
309 ///
310 /// Clickear dos veces el mismo día da un rango de un día, no un rango
311 /// vacío: es una elección legítima (una reserva de una noche).
312 pub fn click(self, d: NaiveDate) -> Self {
313 match (self.inicio, self.fin) {
314 (Some(_), Some(_)) | (None, Some(_)) => Self { inicio: Some(d), fin: None },
315 (Some(a), None) => Self {
316 inicio: Some(a.min(d)),
317 fin: Some(a.max(d)),
318 },
319 (None, None) => Self { inicio: Some(d), fin: None },
320 }
321 }
322
323 /// Los dos extremos ordenados, o `None` si el rango está a medio hacer.
324 pub fn normalizado(&self) -> Option<(NaiveDate, NaiveDate)> {
325 match (self.inicio, self.fin) {
326 (Some(a), Some(b)) => Some((a.min(b), a.max(b))),
327 _ => None,
328 }
329 }
330
331 /// ¿`d` cae dentro del rango (extremos incluidos)? Con el rango a medio
332 /// hacer, sólo el extremo ya elegido cuenta.
333 pub fn contiene(&self, d: NaiveDate) -> bool {
334 match self.normalizado() {
335 Some((a, b)) => d >= a && d <= b,
336 None => self.inicio == Some(d) || self.fin == Some(d),
337 }
338 }
339
340 /// ¿`d` es uno de los dos extremos? Lo usa el pintado para redondear las
341 /// puntas de la banda y dejar cuadrado lo del medio.
342 pub fn es_extremo(&self, d: NaiveDate) -> bool {
343 match self.normalizado() {
344 Some((a, b)) => d == a || d == b,
345 None => self.inicio == Some(d) || self.fin == Some(d),
346 }
347 }
348
349 /// Cuántos días abarca (extremos incluidos); `0` si está a medio hacer.
350 pub fn dias(&self) -> u32 {
351 match self.normalizado() {
352 Some((a, b)) => (b.signed_duration_since(a).num_days() + 1) as u32,
353 None => 0,
354 }
355 }
356
357 /// El rango recortado a los límites, o `None` si queda entero afuera. Sirve
358 /// para no guardar en el `Model` un rango que el calendario no dejaría
359 /// elegir (p. ej. tras cambiar los límites con un rango ya puesto).
360 ///
361 /// Es una **intersección**, no dos clampeos: clampear cada extremo por su
362 /// lado convierte un rango que quedó entero antes del mínimo en uno
363 /// degenerado pegado al mínimo —o sea, inventa una selección que nadie
364 /// hizo—. Si los tramos no se cruzan, no hay rango.
365 pub fn recortado(&self, lim: &Limites) -> Option<Self> {
366 let (a, b) = self.normalizado()?;
367 let a2 = lim.min.map_or(a, |m| a.max(m));
368 let b2 = lim.max.map_or(b, |m| b.min(m));
369 (a2 <= b2).then_some(Self { inicio: Some(a2), fin: Some(b2) })
370 }
371}
372
373/// Paleta del calendario.
374#[derive(Debug, Clone, Copy)]
375pub struct CalendarPalette {
376 /// Fondo del panel.
377 pub bg: Color,
378 /// Texto normal de los días del mes en foco.
379 pub fg: Color,
380 /// Texto de las iniciales de día / mes/año del header (atenuado).
381 pub fg_muted: Color,
382 /// Hover sobre una celda de día.
383 pub hover_bg: Color,
384 /// Fondo de la celda del día seleccionado.
385 pub selected_bg: Color,
386 /// Texto del día seleccionado (contraste contra `selected_bg`).
387 pub selected_fg: Color,
388 /// Color del borde de la celda "hoy" (sólo borde — el fondo sigue al
389 /// hover/selected normal).
390 pub today_border: Color,
391 /// Fondo de los días **entre** los dos extremos de un rango. Los extremos
392 /// llevan `selected_bg`; esto es la banda del medio, más tenue para que se
393 /// lea como "tramo" y no como siete días elegidos uno por uno.
394 pub range_bg: Color,
395 /// El punto de los días **marcados** ([`CalendarSpec::marcados`]).
396 pub marca: Color,
397}
398
399impl CalendarPalette {
400 pub fn from_theme(t: &Theme) -> Self {
401 Self {
402 bg: t.bg_panel,
403 fg: t.fg_text,
404 fg_muted: t.fg_muted,
405 hover_bg: t.bg_row_hover,
406 selected_bg: t.accent,
407 // El acento a un cuarto de opacidad: la banda tiene que dejar leer
408 // el número del día y quedar por debajo de los extremos, no
409 // competir con ellos.
410 marca: t.accent,
411 range_bg: t.accent.with_alpha(0.25),
412 // El texto sobre `accent` se elige por contraste — el theme no
413 // expone un `fg_on_accent` dedicado, así que tomamos `bg_app`
414 // (oscuro en dark theme, claro en light theme) que es el
415 // inverso natural del `fg_text`.
416 selected_fg: t.bg_app,
417 today_border: t.accent,
418 }
419 }
420}
421
422impl Default for CalendarPalette {
423 fn default() -> Self {
424 Self::from_theme(&Theme::dark())
425 }
426}
427
428/// Especificación del calendario para una vista mensual.
429pub struct CalendarSpec<Msg> {
430 /// Año del mes en foco.
431 pub view_year: i32,
432 /// Mes en foco (1..=12).
433 pub view_month: u32,
434 /// Día seleccionado (opcional; si cae en otro mes no se resalta).
435 pub selected: Option<NaiveDate>,
436 /// Fecha "hoy" (opcional; si cae en otro mes no se resalta). El caller
437 /// la inyecta — el widget no toca el reloj para mantenerse puro y
438 /// testeable.
439 pub today: Option<NaiveDate>,
440 /// Primer día de la semana en la grilla.
441 pub week_start: WeekStart,
442 pub palette: CalendarPalette,
443 /// `Msg` para "seleccioné el día X (NaiveDate del mes en foco)".
444 pub on_select: alloc::sync::Arc<dyn Fn(NaiveDate) -> Msg + Send + Sync>,
445 /// `Msg` para "muévete al mes (year, month)" — disparado por `<` / `>`.
446 pub on_view_change: alloc::sync::Arc<dyn Fn(i32, u32) -> Msg + Send + Sync>,
447 /// **Los días que tienen algo**, con un punto debajo del número.
448 ///
449 /// Un punto y no un color de fondo: el fondo ya lo usan la selección, el
450 /// rango y el hover, y un cuarto significado en el mismo canal se lee como
451 /// un estado del calendario y no como «acá hay algo». El punto es una capa
452 /// aparte y convive con los tres.
453 ///
454 /// Vacío = ningún punto, que es como se comportaba antes de existir: un
455 /// calendario que no tiene con qué marcar días no dibuja nada de más.
456 pub marcados: alloc::vec::Vec<NaiveDate>,
457}
458
459/// Lo que [`calendar_view`] no ofrece y [`calendar_view_ext`] sí. Va aparte del
460/// [`CalendarSpec`] a propósito: el `Default` es **exactamente** el calendario
461/// de siempre, así que sumar una opción no toca a ningún caller (mismo criterio
462/// que `segmented_view` / `segmented_view_sliding`).
463#[derive(Debug, Clone, Copy, Default)]
464pub struct CalendarOpciones {
465 /// Días fuera de estos límites se pintan apagados y no se pueden clickear;
466 /// las flechas que llevarían a un mes entero fuera de rango se apagan.
467 pub limites: Limites,
468 /// Selección de rango. Con `Some`, la banda manda sobre `spec.selected`
469 /// (que queda ignorado): son dos modos de elegir, no dos resaltados que se
470 /// pisan.
471 pub rango: Option<RangoSeleccion>,
472 /// Agrega `«` / `»` al header para saltar de **año** (en una fila propia
473 /// arriba de la del mes). Opt-in porque agrega alto.
474 pub navegacion_anual: bool,
475 /// Suma `⟪` / `⟫` (±10 años) a la fila del año. Lo pidió un caller real:
476 /// para una fecha de nacimiento, mover de a un año son treinta clicks, y
477 /// cosmos se había escrito su propia tira de década al costado del
478 /// calendario justamente por esto. Requiere [`Self::navegacion_anual`].
479 pub salto_decada: bool,
480 /// Los textos del calendario en el idioma de quien lo mira. El default es
481 /// [`NombresCalendario::ES`], que es lo que el widget pintaba siempre.
482 pub nombres: NombresCalendario,
483}
484
485/// **Todo lo que el calendario escribe**, para pintarlo en otro idioma.
486///
487/// Va como tabla que pasa quien llama y no como una consulta a
488/// `rimay-localize` porque este crate es `no_std` —corre también en el kernel
489/// de wawa y en una jaula—, y el catálogo de traducciones no. El que tiene el
490/// catálogo arma la tabla; el widget sólo la usa. Los `&'static str` son para
491/// que [`CalendarOpciones`] siga siendo `Copy`.
492#[derive(Debug, Clone, Copy, PartialEq, Eq)]
493pub struct NombresCalendario {
494 /// Los doce meses, enero primero.
495 pub meses: [&'static str; 12],
496 /// Las iniciales de los días, **empezando el lunes** (el `WeekStart` las
497 /// rota).
498 pub iniciales: [&'static str; 7],
499 pub mes_anterior: &'static str,
500 pub mes_siguiente: &'static str,
501 pub anio_anterior: &'static str,
502 pub anio_siguiente: &'static str,
503 pub decada_atras: &'static str,
504 pub decada_adelante: &'static str,
505 /// El nombre accesible de un día, con `%d` (día), `%m` (mes) y `%y` (año):
506 /// `"%d de %m de %y"`. El número solo («14») no ubica a nadie.
507 pub fecha_dia: &'static str,
508}
509
510impl NombresCalendario {
511 /// Castellano — el calendario de siempre, carácter por carácter.
512 pub const ES: NombresCalendario = NombresCalendario {
513 meses: [
514 "enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto",
515 "septiembre", "octubre", "noviembre", "diciembre",
516 ],
517 iniciales: ["L", "M", "M", "J", "V", "S", "D"],
518 mes_anterior: "Mes anterior",
519 mes_siguiente: "Mes siguiente",
520 anio_anterior: "Año anterior",
521 anio_siguiente: "Año siguiente",
522 decada_atras: "Diez años atrás",
523 decada_adelante: "Diez años adelante",
524 fecha_dia: "%d de %m de %y",
525 };
526
527 /// El nombre del mes `m` (1..=12).
528 pub fn mes(&self, m: u32) -> &'static str {
529 match m {
530 1..=12 => self.meses[(m - 1) as usize],
531 _ => "—",
532 }
533 }
534
535 /// Las iniciales en el orden que dicta `inicio`.
536 pub fn iniciales_desde(&self, inicio: WeekStart) -> [&'static str; 7] {
537 let i = self.iniciales;
538 match inicio {
539 WeekStart::Monday => i,
540 WeekStart::Sunday => [i[6], i[0], i[1], i[2], i[3], i[4], i[5]],
541 }
542 }
543
544 /// El nombre accesible del día `d` del mes `m` del año `y`.
545 pub fn nombre_del_dia(&self, d: u32, m: u32, y: i32) -> alloc::string::String {
546 self.fecha_dia
547 .replace("%d", &format!("{d}"))
548 .replace("%m", self.mes(m))
549 .replace("%y", &format!("{y}"))
550 }
551}
552
553impl Default for NombresCalendario {
554 fn default() -> Self {
555 Self::ES
556 }
557}
558
559const HEADER_H: f32 = 32.0;
560const ROW_H: f32 = 32.0;
561const CELL_W: f32 = 32.0;
562const PAD: f32 = 8.0;
563
564/// Un botón de navegación del header (`‹ › « »`). `destino = None` lo deja
565/// **apagado**: sin click, sin hover, texto atenuado y `aria_disabled` — que es
566/// lo que corresponde cuando el mes al que llevaría está entero fuera de los
567/// límites. Se apaga en vez de desaparecer para que el header no cambie de
568/// ancho entre un mes y el siguiente.
569fn nav_button<Msg>(
570 glifo: &'static str,
571 etiqueta: &'static str,
572 destino: Option<(i32, u32)>,
573 palette: &CalendarPalette,
574 on_view_change: &alloc::sync::Arc<dyn Fn(i32, u32) -> Msg + Send + Sync>,
575) -> View<Msg>
576where
577 Msg: Clone + 'static,
578{
579 let habil = destino.is_some();
580 let fg = if habil { palette.fg_muted } else { palette.fg_muted.with_alpha(0.35) };
581 let v = View::<Msg>::new(Style {
582 size: Size { width: length(CELL_W), height: length(HEADER_H) },
583 align_items: Some(AlignItems::CENTER),
584 justify_content: Some(JustifyContent::CENTER),
585 ..Default::default()
586 })
587 .text(glifo, 18.0, fg)
588 .radius(6.0)
589 // Los chevrones son glifos sueltos: sin label el lector diría "‹".
590 .role(llimphi_compositor::Role::Button)
591 .aria_label(etiqueta);
592 match destino {
593 Some((y, m)) => {
594 let f = on_view_change.clone();
595 v.hover_fill(palette.hover_bg)
596 .cursor(llimphi_compositor::Cursor::Pointer)
597 .on_click_at(move |_, _, _, _| Some(f(y, m)))
598 }
599 None => v.aria_disabled(true),
600 }
601}
602
603/// Vista del calendario. Devuelve un `View<Msg>` autocontenido (panel +
604/// header + week labels + grilla 6×7), de ancho fijo `7 * CELL_W + 2 * PAD`.
605pub fn calendar_view<Msg>(spec: CalendarSpec<Msg>) -> View<Msg>
606where
607 Msg: Clone + 'static,
608{
609 calendar_view_ext(spec, CalendarOpciones::default())
610}
611
612/// [`calendar_view`] con [`CalendarOpciones`]: límites min/max, selección de
613/// **rango** y salto de año. Con `CalendarOpciones::default()` es idéntico a
614/// [`calendar_view`], carácter por carácter del árbol.
615pub fn calendar_view_ext<Msg>(spec: CalendarSpec<Msg>, opciones: CalendarOpciones) -> View<Msg>
616where
617 Msg: Clone + 'static,
618{
619 let CalendarSpec {
620 view_year,
621 view_month,
622 selected,
623 today,
624 week_start,
625 palette,
626 on_select,
627 on_view_change,
628 marcados,
629 } = spec;
630 let CalendarOpciones { limites, rango, navegacion_anual, salto_decada, nombres } = opciones;
631
632 // Header: `‹ septiembre 2026 ›`, y con navegación anual una fila más
633 // arriba, `« 2026 »`. Dos filas y no cinco botones en una: el ancho del
634 // calendario lo fija la grilla (7 celdas), y meter cuatro flechas en esa
635 // fila le deja al mes menos de cien píxeles — «septiembre» no entra. Con la
636 // fila extra el calendario conserva su ancho y sólo crece de alto.
637 let destino_si_habil = |delta: i32| {
638 let (y, m) = shift_month(view_year, view_month, delta);
639 limites.mes_habil(y, m).then_some((y, m))
640 };
641 let titulo = |texto: alloc::string::String, size: f32| {
642 View::<Msg>::new(Style {
643 size: Size { width: percent(1.0_f32), height: length(HEADER_H) },
644 flex_grow: 1.0,
645 align_items: Some(AlignItems::CENTER),
646 justify_content: Some(JustifyContent::CENTER),
647 ..Default::default()
648 })
649 .text(texto, size, palette.fg)
650 .role(llimphi_compositor::Role::Heading)
651 };
652 let fila_header = |hijos: Vec<View<Msg>>| {
653 View::<Msg>::new(Style {
654 flex_direction: FlexDirection::Row,
655 align_items: Some(AlignItems::CENTER),
656 size: Size { width: percent(1.0_f32), height: length(HEADER_H) },
657 ..Default::default()
658 })
659 .children(hijos)
660 };
661
662 let prev = nav_button("‹", nombres.mes_anterior, destino_si_habil(-1), &palette, &on_view_change);
663 let next = nav_button("›", nombres.mes_siguiente, destino_si_habil(1), &palette, &on_view_change);
664 let header = if navegacion_anual {
665 let mut cols_anio: Vec<View<Msg>> = Vec::with_capacity(5);
666 if salto_decada {
667 cols_anio.push(nav_button(
668 "⟪",
669 nombres.decada_atras,
670 destino_si_habil(-120),
671 &palette,
672 &on_view_change,
673 ));
674 }
675 cols_anio.push(nav_button(
676 "«",
677 nombres.anio_anterior,
678 destino_si_habil(-12),
679 &palette,
680 &on_view_change,
681 ));
682 cols_anio.push(titulo(format!("{view_year}"), 14.0));
683 cols_anio.push(nav_button(
684 "»",
685 nombres.anio_siguiente,
686 destino_si_habil(12),
687 &palette,
688 &on_view_change,
689 ));
690 if salto_decada {
691 cols_anio.push(nav_button(
692 "⟫",
693 nombres.decada_adelante,
694 destino_si_habil(120),
695 &palette,
696 &on_view_change,
697 ));
698 }
699 let fila_anio = fila_header(cols_anio);
700 let fila_mes =
701 fila_header(vec![prev, titulo(nombres.mes(view_month).to_string(), 14.0), next]);
702 View::<Msg>::new(Style {
703 flex_direction: FlexDirection::Column,
704 size: Size { width: percent(1.0_f32), height: length(HEADER_H * 2.0) },
705 ..Default::default()
706 })
707 .children(vec![fila_anio, fila_mes])
708 } else {
709 fila_header(vec![
710 prev,
711 titulo(format!("{} {}", nombres.mes(view_month), view_year), 14.0),
712 next,
713 ])
714 };
715
716 // Week labels (L M M J V S D o D L M M J V S).
717 let mut week_cols: Vec<View<Msg>> = Vec::with_capacity(7);
718 for label in nombres.iniciales_desde(week_start) {
719 week_cols.push(
720 View::<Msg>::new(Style {
721 size: Size { width: length(CELL_W), height: length(ROW_H * 0.75) },
722 align_items: Some(AlignItems::CENTER),
723 justify_content: Some(JustifyContent::CENTER),
724 ..Default::default()
725 })
726 .text(label, 11.0, palette.fg_muted),
727 );
728 }
729 let week_row = View::<Msg>::new(Style {
730 flex_direction: FlexDirection::Row,
731 size: Size {
732 width: length(CELL_W * 7.0),
733 height: length(ROW_H * 0.75),
734 },
735 ..Default::default()
736 })
737 .children(week_cols);
738
739 // Grilla 6×7.
740 let grid = month_grid(view_year, view_month, week_start);
741 let mut grid_rows: Vec<View<Msg>> = Vec::with_capacity(6);
742 for row in grid.iter() {
743 let mut cells: Vec<View<Msg>> = Vec::with_capacity(7);
744 for cell in row.iter() {
745 let view: View<Msg> = match *cell {
746 None => View::<Msg>::new(Style {
747 size: Size { width: length(CELL_W), height: length(ROW_H) },
748 ..Default::default()
749 }),
750 Some(day) => {
751 let date = NaiveDate::from_ymd_opt(view_year, view_month, day)
752 .expect("día válido del mes en foco");
753 let habil = limites.permite(date);
754 // Con rango pedido manda el rango; sin él, la selección
755 // simple de siempre. Nunca los dos a la vez.
756 let (is_sel, en_banda) = match rango {
757 Some(r) => (r.es_extremo(date), r.contiene(date) && !r.es_extremo(date)),
758 None => (selected == Some(date), false),
759 };
760 let is_today = today == Some(date);
761 let fg = if !habil {
762 // Apagado: el día se sigue leyendo (ubica en la grilla)
763 // pero se ve que no es elegible.
764 palette.fg_muted.with_alpha(0.45)
765 } else if is_sel {
766 palette.selected_fg
767 } else {
768 palette.fg
769 };
770 let mut v = View::<Msg>::new(Style {
771 size: Size { width: length(CELL_W), height: length(ROW_H) },
772 align_items: Some(AlignItems::CENTER),
773 justify_content: Some(JustifyContent::CENTER),
774 ..Default::default()
775 })
776 .text(day.to_string(), 13.0, fg)
777 // El medio de la banda va **cuadrado** para que los días
778 // consecutivos se toquen y se lea un tramo continuo; las
779 // puntas conservan el redondeo.
780 .radius(if en_banda { 0.0 } else { 6.0 })
781 // El número solo ("14") no ubica a nadie: el nombre
782 // accesible lleva la fecha completa, y `checked` marca
783 // cuál está elegida.
784 .role(llimphi_compositor::Role::Button)
785 .aria_label(nombres.nombre_del_dia(day, view_month, view_year))
786 .aria_checked(is_sel || en_banda);
787 if is_sel {
788 v = v.fill(palette.selected_bg);
789 } else if en_banda {
790 v = v.fill(palette.range_bg);
791 } else if habil {
792 v = v.hover_fill(palette.hover_bg);
793 }
794 if is_today {
795 v = v.border(1.0, palette.today_border);
796 }
797 // El punto de «acá hay algo». Va pintado y no como hijo: la
798 // celda ya es un nodo con texto centrado, y meterle un hijo
799 // correría el número. Debajo del número, que es donde no
800 // pisa nada — ni el borde de hoy ni el relleno de la
801 // selección.
802 if marcados.contains(&date) {
803 let color = if is_sel { palette.selected_fg } else { palette.marca };
804 v = v.paint_with(move |scene, _ts, rect| {
805 pintar_marca(scene, rect, color);
806 });
807 }
808 if habil {
809 let f = on_select.clone();
810 v = v
811 .cursor(llimphi_compositor::Cursor::Pointer)
812 .on_click_at(move |_, _, _, _| Some(f(date)));
813 } else {
814 v = v.aria_disabled(true);
815 }
816 v
817 }
818 };
819 cells.push(view);
820 }
821 grid_rows.push(
822 View::<Msg>::new(Style {
823 flex_direction: FlexDirection::Row,
824 size: Size { width: length(CELL_W * 7.0), height: length(ROW_H) },
825 ..Default::default()
826 })
827 .role(llimphi_compositor::Role::Row)
828 .children(cells),
829 );
830 }
831 let grid_box = View::<Msg>::new(Style {
832 flex_direction: FlexDirection::Column,
833 size: Size {
834 width: length(CELL_W * 7.0),
835 height: length(ROW_H * 6.0),
836 },
837 ..Default::default()
838 })
839 .role(llimphi_compositor::Role::Table)
840 .aria_label("Días del mes")
841 .children(grid_rows);
842
843 let total_w = CELL_W * 7.0 + PAD * 2.0;
844 let filas_header = if navegacion_anual { 2.0 } else { 1.0 };
845 let total_h = HEADER_H * filas_header + ROW_H * 0.75 + ROW_H * 6.0 + PAD * 2.0;
846 View::<Msg>::new(Style {
847 flex_direction: FlexDirection::Column,
848 size: Size { width: length(total_w), height: length(total_h) },
849 padding: llimphi_compositor::llimphi_layout::taffy::Rect {
850 top: length(PAD),
851 bottom: length(PAD),
852 left: length(PAD),
853 right: length(PAD),
854 },
855 ..Default::default()
856 })
857 .fill(palette.bg)
858 .radius(8.0)
859 .children(vec![header, week_row, grid_box])
860}
861
862/// Un punto chico en la base de la celda: «este día tiene algo».
863///
864/// El radio sale del alto de la fila y no de un número fijo, para que el punto
865/// no se pierda si alguien agranda las celdas.
866fn pintar_marca(
867 scene: &mut llimphi_compositor::Lienzo,
868 rect: llimphi_compositor::PaintRect,
869 color: Color,
870) {
871 use llimphi_compositor::peniko::kurbo::{Affine, Circle, Point};
872 use llimphi_compositor::peniko::Fill;
873 if rect.w <= 0.0 || rect.h <= 0.0 {
874 return;
875 }
876 let r = (rect.h * 0.075).clamp(1.0, 2.5) as f64;
877 let cx = (rect.x + rect.w * 0.5) as f64;
878 // A dos radios del borde de abajo: pegado al canto se corta con el redondeo
879 // de la celda seleccionada.
880 let cy = (rect.y + rect.h) as f64 - r * 2.2;
881 scene.fill(
882 Fill::NonZero,
883 Affine::IDENTITY,
884 color,
885 None,
886 &Circle::new(Point::new(cx, cy), r),
887 );
888}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893
894 #[test]
895 fn los_nombres_de_fabrica_son_los_de_siempre_y_se_pueden_cambiar() {
896 let es = NombresCalendario::default();
897 // El default no le cambia nada a ningún caller.
898 assert_eq!(es.iniciales_desde(WeekStart::Monday), WeekStart::Monday.weekday_initials_es());
899 assert_eq!(es.iniciales_desde(WeekStart::Sunday), WeekStart::Sunday.weekday_initials_es());
900 assert_eq!(es.nombre_del_dia(7, 8, 2026), "7 de agosto de 2026");
901 assert_eq!(es.mes(13), "—");
902 let en = NombresCalendario { fecha_dia: "%m %d, %y", meses: ["January"; 12], ..es };
903 assert_eq!(en.nombre_del_dia(7, 8, 2026), "January 7, 2026");
904 }
905
906 #[test]
907 fn nav_date_dia_y_semana_cruzan_meses() {
908 let d = NaiveDate::from_ymd_opt(2026, 1, 31).unwrap();
909 assert_eq!(nav_date(d, CalMove::Day(1)), NaiveDate::from_ymd_opt(2026, 2, 1).unwrap());
910 assert_eq!(nav_date(d, CalMove::Week(1)), NaiveDate::from_ymd_opt(2026, 2, 7).unwrap());
911 let ene1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
912 assert_eq!(nav_date(ene1, CalMove::Day(-1)), NaiveDate::from_ymd_opt(2025, 12, 31).unwrap());
913 }
914
915 #[test]
916 fn nav_date_mes_clampa_el_dia_al_destino() {
917 // 31-ene + 1 mes → febrero no tiene 31 → clampa a 28 (2026 no bisiesto).
918 let d = NaiveDate::from_ymd_opt(2026, 1, 31).unwrap();
919 assert_eq!(nav_date(d, CalMove::Month(1)), NaiveDate::from_ymd_opt(2026, 2, 28).unwrap());
920 // En bisiesto clampa a 29.
921 let d24 = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
922 assert_eq!(nav_date(d24, CalMove::Month(1)), NaiveDate::from_ymd_opt(2024, 2, 29).unwrap());
923 }
924
925 #[test]
926 fn nav_date_home_end_saltan_a_los_bordes_del_mes() {
927 let d = NaiveDate::from_ymd_opt(2026, 2, 15).unwrap();
928 assert_eq!(nav_date(d, CalMove::MonthEdge(false)), NaiveDate::from_ymd_opt(2026, 2, 1).unwrap());
929 assert_eq!(nav_date(d, CalMove::MonthEdge(true)), NaiveDate::from_ymd_opt(2026, 2, 28).unwrap());
930 }
931
932 #[test]
933 fn days_in_month_meses_estandar() {
934 assert_eq!(days_in_month(2026, 1), 31);
935 assert_eq!(days_in_month(2026, 2), 28);
936 assert_eq!(days_in_month(2024, 2), 29); // bisiesto
937 // Los siglos, que es donde la regla se rompe si se escribe de memoria.
938 // Vinieron del calendario que `pata` tenía hecho a mano y que este
939 // widget reemplazó (2026-09-06): la implementación se borró, sus casos
940 // no.
941 assert_eq!(days_in_month(2000, 2), 29); // divisible por 400 → sí
942 assert_eq!(days_in_month(1900, 2), 28); // divisible por 100 y no 400 → no
943 assert_eq!(days_in_month(2026, 4), 30);
944 assert_eq!(days_in_month(2026, 12), 31);
945 // Fuera de rango → 0.
946 assert_eq!(days_in_month(2026, 0), 0);
947 assert_eq!(days_in_month(2026, 13), 0);
948 }
949
950 #[test]
951 fn first_weekday_index_monday_y_sunday_start() {
952 // 1 de enero 2026 cae JUEVES.
953 // Con Monday-start: Mon=0, Tue=1, Wed=2, Thu=3 → index = 3.
954 assert_eq!(first_weekday_index(2026, 1, WeekStart::Monday), 3);
955 // Con Sunday-start: Sun=0, Mon=1, ..., Thu=4.
956 assert_eq!(first_weekday_index(2026, 1, WeekStart::Sunday), 4);
957 // 1 de febrero 2026 cae DOMINGO.
958 // Monday-start: Sun=6.
959 assert_eq!(first_weekday_index(2026, 2, WeekStart::Monday), 6);
960 // Sunday-start: Sun=0.
961 assert_eq!(first_weekday_index(2026, 2, WeekStart::Sunday), 0);
962 // Junio 2026 arranca LUNES: el caso del índice 0, que es el que se cae
963 // solo cuando alguien reescribe la cuenta con un `+ 1` de más. Venía
964 // del calendario a mano de `pata` (ver `days_in_month_meses_estandar`).
965 assert_eq!(first_weekday_index(2026, 6, WeekStart::Monday), 0);
966 }
967
968 #[test]
969 fn month_grid_enero_2026_monday_start() {
970 // Enero 2026: 31 días, primer día jueves (index 3 con Monday-start).
971 let g = month_grid(2026, 1, WeekStart::Monday);
972 // Tres celdas vacías al inicio.
973 assert_eq!(g[0][0], None);
974 assert_eq!(g[0][1], None);
975 assert_eq!(g[0][2], None);
976 assert_eq!(g[0][3], Some(1)); // jueves 1
977 assert_eq!(g[0][6], Some(4)); // domingo 4
978 assert_eq!(g[1][0], Some(5)); // lunes 5
979 // Último día: 31. (31 + 3 - 1) / 7 = 33/7 = 4, % 7 = 5 ⇒ fila 4, col 5.
980 assert_eq!(g[4][5], Some(31));
981 // Fila 5 (sexta) entera vacía.
982 for c in g[5].iter() {
983 assert_eq!(*c, None);
984 }
985 }
986
987 #[test]
988 fn month_grid_febrero_2026_28_dias_domingo_inicio() {
989 // Febrero 2026: 28 días, primer día domingo (index 6 con Monday-start).
990 let g = month_grid(2026, 2, WeekStart::Monday);
991 // Primera fila: seis vacías + Some(1) en col 6 (domingo).
992 for c in 0..6 {
993 assert_eq!(g[0][c], None);
994 }
995 assert_eq!(g[0][6], Some(1));
996 assert_eq!(g[1][0], Some(2)); // lunes 2
997 // 28: (28+6-1)/7 = 33/7 = 4, %7 = 5 ⇒ fila 4 col 5.
998 assert_eq!(g[4][5], Some(28));
999 // Fila 5 entera vacía.
1000 for c in g[5].iter() {
1001 assert_eq!(*c, None);
1002 }
1003 }
1004
1005 #[test]
1006 fn shift_month_wrappea_anios() {
1007 // Enero → diciembre del año previo.
1008 assert_eq!(shift_month(2026, 1, -1), (2025, 12));
1009 // Diciembre → enero del año siguiente.
1010 assert_eq!(shift_month(2026, 12, 1), (2027, 1));
1011 // Salto de varios meses.
1012 assert_eq!(shift_month(2026, 6, -8), (2025, 10));
1013 assert_eq!(shift_month(2026, 6, 18), (2027, 12));
1014 }
1015
1016 #[test]
1017 fn week_start_index_consistente() {
1018 // Monday-start.
1019 assert_eq!(WeekStart::Monday.index(Weekday::Mon), 0);
1020 assert_eq!(WeekStart::Monday.index(Weekday::Sun), 6);
1021 // Sunday-start.
1022 assert_eq!(WeekStart::Sunday.index(Weekday::Sun), 0);
1023 assert_eq!(WeekStart::Sunday.index(Weekday::Mon), 1);
1024 assert_eq!(WeekStart::Sunday.index(Weekday::Sat), 6);
1025 }
1026
1027 fn f(y: i32, m: u32, d: u32) -> NaiveDate {
1028 NaiveDate::from_ymd_opt(y, m, d).expect("fecha de test válida")
1029 }
1030
1031 #[test]
1032 fn nav_date_ano_clampa_el_dia_como_el_mes() {
1033 // 29-feb de un bisiesto + 1 año no existe → 28-feb.
1034 assert_eq!(nav_date(f(2024, 2, 29), CalMove::Year(1)), f(2025, 2, 28));
1035 // Y hacia atrás, a un bisiesto, el día se conserva.
1036 assert_eq!(nav_date(f(2025, 3, 15), CalMove::Year(-1)), f(2024, 3, 15));
1037 // Un año es doce meses, literalmente.
1038 assert_eq!(
1039 nav_date(f(2026, 8, 4), CalMove::Year(2)),
1040 nav_date(f(2026, 8, 4), CalMove::Month(24))
1041 );
1042 }
1043
1044 #[test]
1045 fn el_mapa_de_teclas_es_el_del_picker_nativo() {
1046 use llimphi_compositor::{Key, KeyEvent, KeyState, Modifiers, NamedKey};
1047 let tecla = |k: NamedKey, ctrl: bool| KeyEvent {
1048 key: Key::Named(k),
1049 state: KeyState::Pressed,
1050 text: None,
1051 modifiers: Modifiers { ctrl, ..Default::default() },
1052 repeat: false,
1053 };
1054 assert_eq!(cal_move_desde_tecla(&tecla(NamedKey::ArrowLeft, false)), Some(CalMove::Day(-1)));
1055 assert_eq!(cal_move_desde_tecla(&tecla(NamedKey::ArrowDown, false)), Some(CalMove::Week(1)));
1056 assert_eq!(cal_move_desde_tecla(&tecla(NamedKey::PageUp, false)), Some(CalMove::Month(-1)));
1057 assert_eq!(cal_move_desde_tecla(&tecla(NamedKey::Home, false)), Some(CalMove::MonthEdge(false)));
1058 // Ctrl asciende el salto de mes a salto de año.
1059 assert_eq!(cal_move_desde_tecla(&tecla(NamedKey::PageUp, true)), Some(CalMove::Year(-1)));
1060 assert_eq!(cal_move_desde_tecla(&tecla(NamedKey::PageDown, true)), Some(CalMove::Year(1)));
1061 // Ctrl sobre una flecha NO cambia el movimiento (no inventamos gestos).
1062 assert_eq!(cal_move_desde_tecla(&tecla(NamedKey::ArrowLeft, true)), Some(CalMove::Day(-1)));
1063 // Lo que no es del mapa no dispara nada, y el release tampoco.
1064 assert_eq!(cal_move_desde_tecla(&tecla(NamedKey::Escape, false)), None);
1065 let suelta = KeyEvent { state: KeyState::Released, ..tecla(NamedKey::ArrowLeft, false) };
1066 assert_eq!(cal_move_desde_tecla(&suelta), None);
1067 }
1068
1069 #[test]
1070 fn limites_permiten_los_bordes_y_clampean_afuera() {
1071 let lim = Limites::entre(f(2026, 8, 10), f(2026, 8, 20));
1072 // Inclusive de los dos lados.
1073 assert!(lim.permite(f(2026, 8, 10)));
1074 assert!(lim.permite(f(2026, 8, 20)));
1075 assert!(!lim.permite(f(2026, 8, 9)));
1076 assert!(!lim.permite(f(2026, 8, 21)));
1077 // El clamp lleva al borde más cercano, no al otro.
1078 assert_eq!(lim.clamp(f(2026, 1, 1)), f(2026, 8, 10));
1079 assert_eq!(lim.clamp(f(2026, 12, 31)), f(2026, 8, 20));
1080 assert_eq!(lim.clamp(f(2026, 8, 15)), f(2026, 8, 15));
1081 // Un lado suelto no limita el otro.
1082 assert!(Limites::desde(f(2026, 8, 10)).permite(f(2030, 1, 1)));
1083 assert!(Limites::hasta(f(2026, 8, 10)).permite(f(1990, 1, 1)));
1084 // Sin límites, todo pasa.
1085 assert!(Limites::default().permite(f(1, 1, 1)));
1086 }
1087
1088 #[test]
1089 fn mes_habil_mira_el_cruce_del_tramo_no_el_dia_1() {
1090 // Límite a mitad de agosto: agosto ES hábil (tiene días de los dos
1091 // lados), julio no, septiembre sí.
1092 let lim = Limites::desde(f(2026, 8, 15));
1093 assert!(lim.mes_habil(2026, 8));
1094 assert!(!lim.mes_habil(2026, 7));
1095 assert!(lim.mes_habil(2026, 9));
1096 // El caso que delata mirar sólo el día 1: el mínimo es el ÚLTIMO día
1097 // del mes. El mes sigue siendo hábil por ese día.
1098 let borde = Limites::desde(f(2026, 8, 31));
1099 assert!(borde.mes_habil(2026, 8));
1100 assert!(!borde.mes_habil(2026, 7));
1101 // Mes inválido nunca es hábil.
1102 assert!(!Limites::default().mes_habil(2026, 13));
1103 }
1104
1105 #[test]
1106 fn nav_limitada_aterriza_en_el_borde_en_vez_de_salirse() {
1107 let lim = Limites::entre(f(2026, 8, 10), f(2026, 8, 20));
1108 // Una semana atrás desde el 12 se pasaría al 5 → queda en el mínimo.
1109 assert_eq!(nav_date_limitada(f(2026, 8, 12), CalMove::Week(-1), &lim), f(2026, 8, 10));
1110 // Un mes adelante se iría a septiembre → queda en el máximo.
1111 assert_eq!(nav_date_limitada(f(2026, 8, 15), CalMove::Month(1), &lim), f(2026, 8, 20));
1112 // Adentro, se comporta igual que `nav_date`.
1113 assert_eq!(nav_date_limitada(f(2026, 8, 15), CalMove::Day(1), &lim), f(2026, 8, 16));
1114 }
1115
1116 #[test]
1117 fn el_rango_se_arma_en_tres_clicks_y_se_ordena_solo() {
1118 let r = RangoSeleccion::default();
1119 // 1º click: medio rango, sin fin todavía.
1120 let r = r.click(f(2026, 8, 10));
1121 assert_eq!(r.inicio, Some(f(2026, 8, 10)));
1122 assert_eq!(r.fin, None);
1123 assert_eq!(r.dias(), 0, "a medio hacer no abarca días");
1124 assert!(r.contiene(f(2026, 8, 10)), "el extremo ya elegido cuenta");
1125 assert!(!r.contiene(f(2026, 8, 11)));
1126 // 2º click ANTES del inicio: se ordena solo (corregirse hacia atrás es
1127 // legítimo, no debería reiniciar).
1128 let r = r.click(f(2026, 8, 5));
1129 assert_eq!(r.normalizado(), Some((f(2026, 8, 5), f(2026, 8, 10))));
1130 assert_eq!(r.dias(), 6);
1131 assert!(r.contiene(f(2026, 8, 7)));
1132 assert!(r.es_extremo(f(2026, 8, 5)) && r.es_extremo(f(2026, 8, 10)));
1133 assert!(!r.es_extremo(f(2026, 8, 7)));
1134 // 3º click: empieza uno nuevo.
1135 let r = r.click(f(2026, 9, 1));
1136 assert_eq!(r.inicio, Some(f(2026, 9, 1)));
1137 assert_eq!(r.fin, None);
1138 }
1139
1140 #[test]
1141 fn dos_clicks_al_mismo_dia_dan_un_rango_de_un_dia() {
1142 // Una reserva de una noche es una elección legítima, no un rango vacío.
1143 let r = RangoSeleccion::default().click(f(2026, 8, 10)).click(f(2026, 8, 10));
1144 assert_eq!(r.normalizado(), Some((f(2026, 8, 10), f(2026, 8, 10))));
1145 assert_eq!(r.dias(), 1);
1146 assert_eq!(RangoSeleccion::dia(f(2026, 8, 10)), r);
1147 }
1148
1149 #[test]
1150 fn el_rango_se_recorta_a_los_limites_o_se_cae() {
1151 let lim = Limites::entre(f(2026, 8, 10), f(2026, 8, 20));
1152 // Desborda por izquierda: se recorta al mínimo.
1153 let r = RangoSeleccion { inicio: Some(f(2026, 8, 1)), fin: Some(f(2026, 8, 15)) };
1154 assert_eq!(
1155 r.recortado(&lim).and_then(|r| r.normalizado()),
1156 Some((f(2026, 8, 10), f(2026, 8, 15)))
1157 );
1158 // Entero afuera: no queda nada que guardar.
1159 let fuera = RangoSeleccion { inicio: Some(f(2026, 1, 1)), fin: Some(f(2026, 1, 5)) };
1160 assert_eq!(fuera.recortado(&lim), None);
1161 // A medio hacer no se recorta (no hay rango todavía).
1162 assert_eq!(RangoSeleccion::default().click(f(2026, 8, 15)).recortado(&lim), None);
1163 }
1164
1165 /// **El punto no mueve nada.** Va pintado y no como hijo del nodo: si fuera
1166 /// un hijo, la celda con marca sería más alta que las otras y la grilla se
1167 /// desalinearía sola los meses con eventos. Se mide el alto con y sin marcas.
1168 #[test]
1169 fn marcar_dias_no_corre_la_grilla() {
1170 use llimphi_compositor::measured_height;
1171 let theme = llimphi_theme::Theme::default();
1172 let spec = |marcados: alloc::vec::Vec<NaiveDate>| CalendarSpec::<()> {
1173 view_year: 2026,
1174 view_month: 9,
1175 selected: NaiveDate::from_ymd_opt(2026, 9, 7),
1176 today: NaiveDate::from_ymd_opt(2026, 9, 7),
1177 week_start: WeekStart::Monday,
1178 palette: CalendarPalette::from_theme(&theme),
1179 on_select: alloc::sync::Arc::new(|_| ()),
1180 on_view_change: alloc::sync::Arc::new(|_, _| ()),
1181 marcados,
1182 };
1183 let sin = measured_height(calendar_view(spec(alloc::vec::Vec::new())), 240.0);
1184 let con = measured_height(
1185 calendar_view(spec(
1186 (1..=28)
1187 .filter_map(|d| NaiveDate::from_ymd_opt(2026, 9, d))
1188 .collect(),
1189 )),
1190 240.0,
1191 );
1192 assert_eq!(sin, con, "el mes entero marcado no puede cambiar el alto");
1193 assert!(sin > 0.0);
1194 }
1195
1196 /// Un día marcado que **no está en el mes en foco** no rompe nada: el
1197 /// llamador pide el mes entero y puede pasarse de largo.
1198 #[test]
1199 fn una_marca_de_otro_mes_se_ignora() {
1200 use llimphi_compositor::measured_height;
1201 let theme = llimphi_theme::Theme::default();
1202 let spec = CalendarSpec::<()> {
1203 view_year: 2026,
1204 view_month: 9,
1205 selected: None,
1206 today: None,
1207 week_start: WeekStart::Monday,
1208 palette: CalendarPalette::from_theme(&theme),
1209 on_select: alloc::sync::Arc::new(|_| ()),
1210 on_view_change: alloc::sync::Arc::new(|_, _| ()),
1211 marcados: alloc::vec![NaiveDate::from_ymd_opt(2026, 3, 1).unwrap()],
1212 };
1213 assert!(measured_height(calendar_view(spec), 240.0) > 0.0);
1214 }
1215
1216}