tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main widgets/calendar/src/lib.rs Rust · 1216 líneas · 50.0 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · 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
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}