//! `llimphi-widget-calendar` — vista mensual del calendario. //! //! Una grilla 7 (columnas = días de la semana) × 6 (filas = semanas) //! mostrando los días del mes en foco, con header de mes/año + flechas de //! navegación, fila de iniciales de día, día seleccionado y día actual //! resaltados. El widget **no tiene estado propio**: el mes en foco, la //! selección y la fecha "hoy" viven en el `Model` del caller; el widget //! emite `Msg`s para navegar y para seleccionar. //! //! Base del **date-picker** (un `field` + un overlay que muestra este //! calendar) y útil por sí solo para agendas, planning, ERP. La lógica //! del calendario (días del mes, primer día de la semana, grilla 6×7) es //! pública y testeable: ver [`days_in_month`], [`first_weekday`], //! [`month_grid`]. #![forbid(unsafe_code)] // Sin `std`: este widget no usa nada del sistema —sólo `alloc` para // `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula // WASM o al kernel. Antes enlazaba `std` por omisión (W4.6). #![no_std] extern crate alloc; use alloc::vec::Vec; use alloc::vec; use alloc::format; use alloc::string::ToString; use chrono::{Datelike, NaiveDate, Weekday}; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{length, percent, FlexDirection, Size, Style}, AlignItems, JustifyContent, }; use llimphi_compositor::peniko::Color; use llimphi_compositor::View; use llimphi_theme::Theme; /// Día de la semana con el que arranca la grilla. La mayor parte del mundo /// usa `Monday`; EE. UU. usa `Sunday`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WeekStart { Monday, Sunday, } impl Default for WeekStart { fn default() -> Self { Self::Monday } } impl WeekStart { /// Posición (0..7) del `weekday` empezando por este `WeekStart`. Con /// `Monday`: `Mon=0, Tue=1, …, Sun=6`. Con `Sunday`: `Sun=0, Mon=1, /// …, Sat=6`. pub fn index(self, w: Weekday) -> u32 { let mon0 = w.num_days_from_monday(); match self { Self::Monday => mon0, // Domingo (mon0=6) pasa a 0, lunes (0) a 1, … Self::Sunday => (mon0 + 1) % 7, } } /// Iniciales de los siete días en el orden que dicta este `WeekStart`, /// con `locale = "es"` (L/M/M/J/V/S/D para Monday-start). Suficiente /// para v1; un mapa por `Theme.locale` se agrega cuando alguna app lo /// pida. pub fn weekday_initials_es(self) -> [&'static str; 7] { match self { Self::Monday => ["L", "M", "M", "J", "V", "S", "D"], Self::Sunday => ["D", "L", "M", "M", "J", "V", "S"], } } } /// Cantidad de días del mes `month` (1..=12) del año `year`. Devuelve `0` /// si `month` está fuera de rango (defensa contra `u32` arbitrario; un /// caller correcto pasa 1..=12). pub fn days_in_month(year: i32, month: u32) -> u32 { if !(1..=12).contains(&month) { return 0; } // Trick estándar: primer día del mes siguiente menos un día. Año // siguiente si month == 12. `from_ymd_opt` retorna `Some` siempre que // (year, month, day) sean válidos — para day=1 con month 1..=12 lo es. let (y2, m2) = if month == 12 { (year + 1, 1) } else { (year, month + 1) }; let first_next = NaiveDate::from_ymd_opt(y2, m2, 1).expect("primer día válido"); let first_this = NaiveDate::from_ymd_opt(year, month, 1).expect("primer día válido"); first_next.signed_duration_since(first_this).num_days() as u32 } /// Día de la semana del **primer día** del mes (1 del mes). Sólo útil para /// quien quiera el `Weekday` crudo; la grilla usa [`first_weekday_index`]. pub fn first_weekday(year: i32, month: u32) -> Option { NaiveDate::from_ymd_opt(year, month, 1).map(|d| d.weekday()) } /// Índice de columna (0..7) del primer día del mes según `WeekStart`. Es /// la cantidad de celdas vacías ANTES del 1 en la primera fila de la /// grilla. Devuelve `0` si el mes es inválido. pub fn first_weekday_index(year: i32, month: u32, start: WeekStart) -> u32 { first_weekday(year, month).map(|w| start.index(w)).unwrap_or(0) } /// Grilla mensual de **6 filas × 7 columnas** con los días del mes. Las /// celdas que caen antes del primer día o después del último día del mes /// son `None`. Siempre seis filas: para meses cortos (28 días arrancando /// en domingo bajo Monday-start, p. ej. febrero 2021) la última fila queda /// vacía; mantener seis filas estables evita reflow al navegar entre /// meses (toda la grilla mide igual). Si `month` es inválido devuelve seis /// filas de siete `None`s. pub fn month_grid(year: i32, month: u32, start: WeekStart) -> [[Option; 7]; 6] { let mut grid: [[Option; 7]; 6] = [[None; 7]; 6]; let dim = days_in_month(year, month); if dim == 0 { return grid; } let offset = first_weekday_index(year, month, start) as usize; for day in 1..=dim { let cell = offset + (day as usize) - 1; let r = cell / 7; let c = cell % 7; if r < 6 { grid[r][c] = Some(day); } // Si r == 6 (no debería pasar para meses estándar: 31+6=37<42), // se ignora; la grilla de 6×7 = 42 celdas alcanza para el peor caso // (31 días con offset = 6 → última celda 36, fila 5). } grid } /// Avanza `(year, month)` un mes adelante (`delta = +1`) o atrás (`-1`), /// wrappeando años. Helper para los botones `<`/`>` del header. pub fn shift_month(year: i32, month: u32, delta: i32) -> (i32, u32) { let total = (year * 12 + month as i32 - 1) + delta; let y = total.div_euclid(12); let m = (total.rem_euclid(12) + 1) as u32; (y, m) } /// Un movimiento de teclado sobre el calendario, tal como lo mapea un /// `` / DatePicker nativo. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CalMove { /// ← / → : un día. Day(i32), /// ↑ / ↓ : una semana (∓7 días). Week(i32), /// PageUp / PageDown : un mes (calendario, preserva el día si existe). Month(i32), /// Ctrl+PageUp / Ctrl+PageDown : un **año**, con la misma regla de /// preservar el día que `Month` (29-feb + 1 año → 28-feb). Year(i32), /// Home / End : primer / último día del mes. MonthEdge(bool), } /// Aplica un [`CalMove`] a la fecha enfocada y devuelve la nueva fecha — /// la fuente de verdad de la navegación por teclado (el host guarda la /// fecha en foco y dispatcha su `Msg` con este resultado). `Day`/`Week` /// usan aritmética de días real (cruzan meses y años); `Month` preserva /// el día clampeándolo al último del mes destino (31-ene + 1 mes → 28/29-feb). pub fn nav_date(date: NaiveDate, mov: CalMove) -> NaiveDate { match mov { CalMove::Day(n) => date + chrono::Duration::days(n as i64), CalMove::Week(n) => date + chrono::Duration::days((n * 7) as i64), CalMove::Month(n) => { let (y, m) = shift_month(date.year(), date.month(), n); let last = days_in_month(y, m); let day = date.day().min(last); NaiveDate::from_ymd_opt(y, m, day).expect("día clampeado al mes es válido") } // Un año son doce meses: así el clampeo del día sale de la misma regla // (29-feb de un bisiesto + 1 año → 28-feb) en vez de tener la suya. CalMove::Year(n) => nav_date(date, CalMove::Month(n.saturating_mul(12))), CalMove::MonthEdge(to_end) => { let day = if to_end { days_in_month(date.year(), date.month()) } else { 1 }; NaiveDate::from_ymd_opt(date.year(), date.month(), day) .expect("borde de mes válido") } } } /// Límites de fecha seleccionable: `[min, max]`, ambos **inclusive**, y un /// extremo en `None` es "sin límite por ese lado". Es lo que hace un /// `` y lo que pide cualquier reserva ("no antes de /// hoy") o cierre contable ("no después del último día del ejercicio"). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Limites { pub min: Option, pub max: Option, } impl Limites { /// Sólo tope inferior. pub fn desde(min: NaiveDate) -> Self { Self { min: Some(min), max: None } } /// Sólo tope superior. pub fn hasta(max: NaiveDate) -> Self { Self { min: None, max: Some(max) } } /// Ambos topes. pub fn entre(min: NaiveDate, max: NaiveDate) -> Self { Self { min: Some(min), max: Some(max) } } /// ¿`d` es seleccionable? Sin límites, todo lo es. pub fn permite(&self, d: NaiveDate) -> bool { self.min.is_none_or(|m| d >= m) && self.max.is_none_or(|m| d <= m) } /// La fecha más cercana a `d` que sí es seleccionable. pub fn clamp(&self, d: NaiveDate) -> NaiveDate { let d = self.min.map_or(d, |m| d.max(m)); self.max.map_or(d, |m| d.min(m)) } /// ¿El mes `(year, month)` tiene **algún** día seleccionable? Es lo que /// decide si la flecha de navegación se apaga: un mes entero fuera de /// límites no es un destino al que tenga sentido llevar a nadie. pub fn mes_habil(&self, year: i32, month: u32) -> bool { let dim = days_in_month(year, month); if dim == 0 { return false; } let primero = NaiveDate::from_ymd_opt(year, month, 1).expect("primer día válido"); let ultimo = NaiveDate::from_ymd_opt(year, month, dim).expect("último día válido"); // El mes es hábil si su tramo [primero, último] se cruza con [min, max]. self.min.is_none_or(|m| ultimo >= m) && self.max.is_none_or(|m| primero <= m) } } /// Traduce una tecla al [`CalMove`] que le corresponde, con el mapa que usa un /// `` nativo. Devuelve `None` para todo lo demás — el host /// pasa el evento y actúa sólo si sale `Some`. /// /// Existe para que el mapa **no lo reinvente cada host**: el enum documentaba /// las teclas en prosa y cada caller las cableaba a mano, que es exactamente /// cómo dos apps terminan navegando distinto el mismo widget. pub fn cal_move_desde_tecla(ev: &llimphi_compositor::KeyEvent) -> Option { use llimphi_compositor::{Key, KeyState, NamedKey}; if ev.state != KeyState::Pressed { return None; } let Key::Named(k) = ev.key else { return None }; // Ctrl convierte el salto de mes en salto de año, como en los pickers // nativos; el resto de los modificadores no cambia el movimiento. let anual = ev.modifiers.ctrl; Some(match k { NamedKey::ArrowLeft => CalMove::Day(-1), NamedKey::ArrowRight => CalMove::Day(1), NamedKey::ArrowUp => CalMove::Week(-1), NamedKey::ArrowDown => CalMove::Week(1), NamedKey::PageUp if anual => CalMove::Year(-1), NamedKey::PageDown if anual => CalMove::Year(1), NamedKey::PageUp => CalMove::Month(-1), NamedKey::PageDown => CalMove::Month(1), NamedKey::Home => CalMove::MonthEdge(false), NamedKey::End => CalMove::MonthEdge(true), _ => return None, }) } /// [`nav_date`] respetando [`Limites`]: si el movimiento se pasa del borde, /// aterriza **en el borde** en vez de irse afuera. Es la regla de un date-picker /// nativo — la tecla nunca queda muerta, pero tampoco te saca del rango. pub fn nav_date_limitada(date: NaiveDate, mov: CalMove, lim: &Limites) -> NaiveDate { lim.clamp(nav_date(date, mov)) } /// Selección de un **rango** de fechas: llegada/salida, desde/hasta de un /// reporte, período de un contrato. /// /// El widget no guarda estado: el host tiene este valor en su `Model` y lo /// avanza con [`RangoSeleccion::click`] cada vez que llega un día elegido. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct RangoSeleccion { pub inicio: Option, pub fin: Option, } impl RangoSeleccion { /// Rango de un solo día. pub fn dia(d: NaiveDate) -> Self { Self { inicio: Some(d), fin: Some(d) } } /// Máquina de estados de los clicks, en tres tiempos: /// /// 1. rango vacío → `d` queda como **inicio**, a la espera del otro extremo; /// 2. medio rango → se cierra con `d`, **ordenado** (clickear antes del /// inicio no rompe nada: pasa a ser el inicio y el viejo inicio el fin, /// que es lo que espera cualquiera que "se corrige hacia atrás"); /// 3. rango completo → empieza uno nuevo desde `d`. /// /// Clickear dos veces el mismo día da un rango de un día, no un rango /// vacío: es una elección legítima (una reserva de una noche). pub fn click(self, d: NaiveDate) -> Self { match (self.inicio, self.fin) { (Some(_), Some(_)) | (None, Some(_)) => Self { inicio: Some(d), fin: None }, (Some(a), None) => Self { inicio: Some(a.min(d)), fin: Some(a.max(d)), }, (None, None) => Self { inicio: Some(d), fin: None }, } } /// Los dos extremos ordenados, o `None` si el rango está a medio hacer. pub fn normalizado(&self) -> Option<(NaiveDate, NaiveDate)> { match (self.inicio, self.fin) { (Some(a), Some(b)) => Some((a.min(b), a.max(b))), _ => None, } } /// ¿`d` cae dentro del rango (extremos incluidos)? Con el rango a medio /// hacer, sólo el extremo ya elegido cuenta. pub fn contiene(&self, d: NaiveDate) -> bool { match self.normalizado() { Some((a, b)) => d >= a && d <= b, None => self.inicio == Some(d) || self.fin == Some(d), } } /// ¿`d` es uno de los dos extremos? Lo usa el pintado para redondear las /// puntas de la banda y dejar cuadrado lo del medio. pub fn es_extremo(&self, d: NaiveDate) -> bool { match self.normalizado() { Some((a, b)) => d == a || d == b, None => self.inicio == Some(d) || self.fin == Some(d), } } /// Cuántos días abarca (extremos incluidos); `0` si está a medio hacer. pub fn dias(&self) -> u32 { match self.normalizado() { Some((a, b)) => (b.signed_duration_since(a).num_days() + 1) as u32, None => 0, } } /// El rango recortado a los límites, o `None` si queda entero afuera. Sirve /// para no guardar en el `Model` un rango que el calendario no dejaría /// elegir (p. ej. tras cambiar los límites con un rango ya puesto). /// /// Es una **intersección**, no dos clampeos: clampear cada extremo por su /// lado convierte un rango que quedó entero antes del mínimo en uno /// degenerado pegado al mínimo —o sea, inventa una selección que nadie /// hizo—. Si los tramos no se cruzan, no hay rango. pub fn recortado(&self, lim: &Limites) -> Option { let (a, b) = self.normalizado()?; let a2 = lim.min.map_or(a, |m| a.max(m)); let b2 = lim.max.map_or(b, |m| b.min(m)); (a2 <= b2).then_some(Self { inicio: Some(a2), fin: Some(b2) }) } } /// Paleta del calendario. #[derive(Debug, Clone, Copy)] pub struct CalendarPalette { /// Fondo del panel. pub bg: Color, /// Texto normal de los días del mes en foco. pub fg: Color, /// Texto de las iniciales de día / mes/año del header (atenuado). pub fg_muted: Color, /// Hover sobre una celda de día. pub hover_bg: Color, /// Fondo de la celda del día seleccionado. pub selected_bg: Color, /// Texto del día seleccionado (contraste contra `selected_bg`). pub selected_fg: Color, /// Color del borde de la celda "hoy" (sólo borde — el fondo sigue al /// hover/selected normal). pub today_border: Color, /// Fondo de los días **entre** los dos extremos de un rango. Los extremos /// llevan `selected_bg`; esto es la banda del medio, más tenue para que se /// lea como "tramo" y no como siete días elegidos uno por uno. pub range_bg: Color, /// El punto de los días **marcados** ([`CalendarSpec::marcados`]). pub marca: Color, } impl CalendarPalette { pub fn from_theme(t: &Theme) -> Self { Self { bg: t.bg_panel, fg: t.fg_text, fg_muted: t.fg_muted, hover_bg: t.bg_row_hover, selected_bg: t.accent, // El acento a un cuarto de opacidad: la banda tiene que dejar leer // el número del día y quedar por debajo de los extremos, no // competir con ellos. marca: t.accent, range_bg: t.accent.with_alpha(0.25), // El texto sobre `accent` se elige por contraste — el theme no // expone un `fg_on_accent` dedicado, así que tomamos `bg_app` // (oscuro en dark theme, claro en light theme) que es el // inverso natural del `fg_text`. selected_fg: t.bg_app, today_border: t.accent, } } } impl Default for CalendarPalette { fn default() -> Self { Self::from_theme(&Theme::dark()) } } /// Especificación del calendario para una vista mensual. pub struct CalendarSpec { /// Año del mes en foco. pub view_year: i32, /// Mes en foco (1..=12). pub view_month: u32, /// Día seleccionado (opcional; si cae en otro mes no se resalta). pub selected: Option, /// Fecha "hoy" (opcional; si cae en otro mes no se resalta). El caller /// la inyecta — el widget no toca el reloj para mantenerse puro y /// testeable. pub today: Option, /// Primer día de la semana en la grilla. pub week_start: WeekStart, pub palette: CalendarPalette, /// `Msg` para "seleccioné el día X (NaiveDate del mes en foco)". pub on_select: alloc::sync::Arc Msg + Send + Sync>, /// `Msg` para "muévete al mes (year, month)" — disparado por `<` / `>`. pub on_view_change: alloc::sync::Arc Msg + Send + Sync>, /// **Los días que tienen algo**, con un punto debajo del número. /// /// Un punto y no un color de fondo: el fondo ya lo usan la selección, el /// rango y el hover, y un cuarto significado en el mismo canal se lee como /// un estado del calendario y no como «acá hay algo». El punto es una capa /// aparte y convive con los tres. /// /// Vacío = ningún punto, que es como se comportaba antes de existir: un /// calendario que no tiene con qué marcar días no dibuja nada de más. pub marcados: alloc::vec::Vec, } /// Lo que [`calendar_view`] no ofrece y [`calendar_view_ext`] sí. Va aparte del /// [`CalendarSpec`] a propósito: el `Default` es **exactamente** el calendario /// de siempre, así que sumar una opción no toca a ningún caller (mismo criterio /// que `segmented_view` / `segmented_view_sliding`). #[derive(Debug, Clone, Copy, Default)] pub struct CalendarOpciones { /// Días fuera de estos límites se pintan apagados y no se pueden clickear; /// las flechas que llevarían a un mes entero fuera de rango se apagan. pub limites: Limites, /// Selección de rango. Con `Some`, la banda manda sobre `spec.selected` /// (que queda ignorado): son dos modos de elegir, no dos resaltados que se /// pisan. pub rango: Option, /// Agrega `«` / `»` al header para saltar de **año** (en una fila propia /// arriba de la del mes). Opt-in porque agrega alto. pub navegacion_anual: bool, /// Suma `⟪` / `⟫` (±10 años) a la fila del año. Lo pidió un caller real: /// para una fecha de nacimiento, mover de a un año son treinta clicks, y /// cosmos se había escrito su propia tira de década al costado del /// calendario justamente por esto. Requiere [`Self::navegacion_anual`]. pub salto_decada: bool, /// Los textos del calendario en el idioma de quien lo mira. El default es /// [`NombresCalendario::ES`], que es lo que el widget pintaba siempre. pub nombres: NombresCalendario, } /// **Todo lo que el calendario escribe**, para pintarlo en otro idioma. /// /// Va como tabla que pasa quien llama y no como una consulta a /// `rimay-localize` porque este crate es `no_std` —corre también en el kernel /// de wawa y en una jaula—, y el catálogo de traducciones no. El que tiene el /// catálogo arma la tabla; el widget sólo la usa. Los `&'static str` son para /// que [`CalendarOpciones`] siga siendo `Copy`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct NombresCalendario { /// Los doce meses, enero primero. pub meses: [&'static str; 12], /// Las iniciales de los días, **empezando el lunes** (el `WeekStart` las /// rota). pub iniciales: [&'static str; 7], pub mes_anterior: &'static str, pub mes_siguiente: &'static str, pub anio_anterior: &'static str, pub anio_siguiente: &'static str, pub decada_atras: &'static str, pub decada_adelante: &'static str, /// El nombre accesible de un día, con `%d` (día), `%m` (mes) y `%y` (año): /// `"%d de %m de %y"`. El número solo («14») no ubica a nadie. pub fecha_dia: &'static str, } impl NombresCalendario { /// Castellano — el calendario de siempre, carácter por carácter. pub const ES: NombresCalendario = NombresCalendario { meses: [ "enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre", ], iniciales: ["L", "M", "M", "J", "V", "S", "D"], mes_anterior: "Mes anterior", mes_siguiente: "Mes siguiente", anio_anterior: "Año anterior", anio_siguiente: "Año siguiente", decada_atras: "Diez años atrás", decada_adelante: "Diez años adelante", fecha_dia: "%d de %m de %y", }; /// El nombre del mes `m` (1..=12). pub fn mes(&self, m: u32) -> &'static str { match m { 1..=12 => self.meses[(m - 1) as usize], _ => "—", } } /// Las iniciales en el orden que dicta `inicio`. pub fn iniciales_desde(&self, inicio: WeekStart) -> [&'static str; 7] { let i = self.iniciales; match inicio { WeekStart::Monday => i, WeekStart::Sunday => [i[6], i[0], i[1], i[2], i[3], i[4], i[5]], } } /// El nombre accesible del día `d` del mes `m` del año `y`. pub fn nombre_del_dia(&self, d: u32, m: u32, y: i32) -> alloc::string::String { self.fecha_dia .replace("%d", &format!("{d}")) .replace("%m", self.mes(m)) .replace("%y", &format!("{y}")) } } impl Default for NombresCalendario { fn default() -> Self { Self::ES } } const HEADER_H: f32 = 32.0; const ROW_H: f32 = 32.0; const CELL_W: f32 = 32.0; const PAD: f32 = 8.0; /// Un botón de navegación del header (`‹ › « »`). `destino = None` lo deja /// **apagado**: sin click, sin hover, texto atenuado y `aria_disabled` — que es /// lo que corresponde cuando el mes al que llevaría está entero fuera de los /// límites. Se apaga en vez de desaparecer para que el header no cambie de /// ancho entre un mes y el siguiente. fn nav_button( glifo: &'static str, etiqueta: &'static str, destino: Option<(i32, u32)>, palette: &CalendarPalette, on_view_change: &alloc::sync::Arc Msg + Send + Sync>, ) -> View where Msg: Clone + 'static, { let habil = destino.is_some(); let fg = if habil { palette.fg_muted } else { palette.fg_muted.with_alpha(0.35) }; let v = View::::new(Style { size: Size { width: length(CELL_W), height: length(HEADER_H) }, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .text(glifo, 18.0, fg) .radius(6.0) // Los chevrones son glifos sueltos: sin label el lector diría "‹". .role(llimphi_compositor::Role::Button) .aria_label(etiqueta); match destino { Some((y, m)) => { let f = on_view_change.clone(); v.hover_fill(palette.hover_bg) .cursor(llimphi_compositor::Cursor::Pointer) .on_click_at(move |_, _, _, _| Some(f(y, m))) } None => v.aria_disabled(true), } } /// Vista del calendario. Devuelve un `View` autocontenido (panel + /// header + week labels + grilla 6×7), de ancho fijo `7 * CELL_W + 2 * PAD`. pub fn calendar_view(spec: CalendarSpec) -> View where Msg: Clone + 'static, { calendar_view_ext(spec, CalendarOpciones::default()) } /// [`calendar_view`] con [`CalendarOpciones`]: límites min/max, selección de /// **rango** y salto de año. Con `CalendarOpciones::default()` es idéntico a /// [`calendar_view`], carácter por carácter del árbol. pub fn calendar_view_ext(spec: CalendarSpec, opciones: CalendarOpciones) -> View where Msg: Clone + 'static, { let CalendarSpec { view_year, view_month, selected, today, week_start, palette, on_select, on_view_change, marcados, } = spec; let CalendarOpciones { limites, rango, navegacion_anual, salto_decada, nombres } = opciones; // Header: `‹ septiembre 2026 ›`, y con navegación anual una fila más // arriba, `« 2026 »`. Dos filas y no cinco botones en una: el ancho del // calendario lo fija la grilla (7 celdas), y meter cuatro flechas en esa // fila le deja al mes menos de cien píxeles — «septiembre» no entra. Con la // fila extra el calendario conserva su ancho y sólo crece de alto. let destino_si_habil = |delta: i32| { let (y, m) = shift_month(view_year, view_month, delta); limites.mes_habil(y, m).then_some((y, m)) }; let titulo = |texto: alloc::string::String, size: f32| { View::::new(Style { size: Size { width: percent(1.0_f32), height: length(HEADER_H) }, flex_grow: 1.0, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .text(texto, size, palette.fg) .role(llimphi_compositor::Role::Heading) }; let fila_header = |hijos: Vec>| { View::::new(Style { flex_direction: FlexDirection::Row, align_items: Some(AlignItems::CENTER), size: Size { width: percent(1.0_f32), height: length(HEADER_H) }, ..Default::default() }) .children(hijos) }; let prev = nav_button("‹", nombres.mes_anterior, destino_si_habil(-1), &palette, &on_view_change); let next = nav_button("›", nombres.mes_siguiente, destino_si_habil(1), &palette, &on_view_change); let header = if navegacion_anual { let mut cols_anio: Vec> = Vec::with_capacity(5); if salto_decada { cols_anio.push(nav_button( "⟪", nombres.decada_atras, destino_si_habil(-120), &palette, &on_view_change, )); } cols_anio.push(nav_button( "«", nombres.anio_anterior, destino_si_habil(-12), &palette, &on_view_change, )); cols_anio.push(titulo(format!("{view_year}"), 14.0)); cols_anio.push(nav_button( "»", nombres.anio_siguiente, destino_si_habil(12), &palette, &on_view_change, )); if salto_decada { cols_anio.push(nav_button( "⟫", nombres.decada_adelante, destino_si_habil(120), &palette, &on_view_change, )); } let fila_anio = fila_header(cols_anio); let fila_mes = fila_header(vec![prev, titulo(nombres.mes(view_month).to_string(), 14.0), next]); View::::new(Style { flex_direction: FlexDirection::Column, size: Size { width: percent(1.0_f32), height: length(HEADER_H * 2.0) }, ..Default::default() }) .children(vec![fila_anio, fila_mes]) } else { fila_header(vec![ prev, titulo(format!("{} {}", nombres.mes(view_month), view_year), 14.0), next, ]) }; // Week labels (L M M J V S D o D L M M J V S). let mut week_cols: Vec> = Vec::with_capacity(7); for label in nombres.iniciales_desde(week_start) { week_cols.push( View::::new(Style { size: Size { width: length(CELL_W), height: length(ROW_H * 0.75) }, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .text(label, 11.0, palette.fg_muted), ); } let week_row = View::::new(Style { flex_direction: FlexDirection::Row, size: Size { width: length(CELL_W * 7.0), height: length(ROW_H * 0.75), }, ..Default::default() }) .children(week_cols); // Grilla 6×7. let grid = month_grid(view_year, view_month, week_start); let mut grid_rows: Vec> = Vec::with_capacity(6); for row in grid.iter() { let mut cells: Vec> = Vec::with_capacity(7); for cell in row.iter() { let view: View = match *cell { None => View::::new(Style { size: Size { width: length(CELL_W), height: length(ROW_H) }, ..Default::default() }), Some(day) => { let date = NaiveDate::from_ymd_opt(view_year, view_month, day) .expect("día válido del mes en foco"); let habil = limites.permite(date); // Con rango pedido manda el rango; sin él, la selección // simple de siempre. Nunca los dos a la vez. let (is_sel, en_banda) = match rango { Some(r) => (r.es_extremo(date), r.contiene(date) && !r.es_extremo(date)), None => (selected == Some(date), false), }; let is_today = today == Some(date); let fg = if !habil { // Apagado: el día se sigue leyendo (ubica en la grilla) // pero se ve que no es elegible. palette.fg_muted.with_alpha(0.45) } else if is_sel { palette.selected_fg } else { palette.fg }; let mut v = View::::new(Style { size: Size { width: length(CELL_W), height: length(ROW_H) }, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .text(day.to_string(), 13.0, fg) // El medio de la banda va **cuadrado** para que los días // consecutivos se toquen y se lea un tramo continuo; las // puntas conservan el redondeo. .radius(if en_banda { 0.0 } else { 6.0 }) // El número solo ("14") no ubica a nadie: el nombre // accesible lleva la fecha completa, y `checked` marca // cuál está elegida. .role(llimphi_compositor::Role::Button) .aria_label(nombres.nombre_del_dia(day, view_month, view_year)) .aria_checked(is_sel || en_banda); if is_sel { v = v.fill(palette.selected_bg); } else if en_banda { v = v.fill(palette.range_bg); } else if habil { v = v.hover_fill(palette.hover_bg); } if is_today { v = v.border(1.0, palette.today_border); } // El punto de «acá hay algo». Va pintado y no como hijo: la // celda ya es un nodo con texto centrado, y meterle un hijo // correría el número. Debajo del número, que es donde no // pisa nada — ni el borde de hoy ni el relleno de la // selección. if marcados.contains(&date) { let color = if is_sel { palette.selected_fg } else { palette.marca }; v = v.paint_with(move |scene, _ts, rect| { pintar_marca(scene, rect, color); }); } if habil { let f = on_select.clone(); v = v .cursor(llimphi_compositor::Cursor::Pointer) .on_click_at(move |_, _, _, _| Some(f(date))); } else { v = v.aria_disabled(true); } v } }; cells.push(view); } grid_rows.push( View::::new(Style { flex_direction: FlexDirection::Row, size: Size { width: length(CELL_W * 7.0), height: length(ROW_H) }, ..Default::default() }) .role(llimphi_compositor::Role::Row) .children(cells), ); } let grid_box = View::::new(Style { flex_direction: FlexDirection::Column, size: Size { width: length(CELL_W * 7.0), height: length(ROW_H * 6.0), }, ..Default::default() }) .role(llimphi_compositor::Role::Table) .aria_label("Días del mes") .children(grid_rows); let total_w = CELL_W * 7.0 + PAD * 2.0; let filas_header = if navegacion_anual { 2.0 } else { 1.0 }; let total_h = HEADER_H * filas_header + ROW_H * 0.75 + ROW_H * 6.0 + PAD * 2.0; View::::new(Style { flex_direction: FlexDirection::Column, size: Size { width: length(total_w), height: length(total_h) }, padding: llimphi_compositor::llimphi_layout::taffy::Rect { top: length(PAD), bottom: length(PAD), left: length(PAD), right: length(PAD), }, ..Default::default() }) .fill(palette.bg) .radius(8.0) .children(vec![header, week_row, grid_box]) } /// Un punto chico en la base de la celda: «este día tiene algo». /// /// El radio sale del alto de la fila y no de un número fijo, para que el punto /// no se pierda si alguien agranda las celdas. fn pintar_marca( scene: &mut llimphi_compositor::Lienzo, rect: llimphi_compositor::PaintRect, color: Color, ) { use llimphi_compositor::peniko::kurbo::{Affine, Circle, Point}; use llimphi_compositor::peniko::Fill; if rect.w <= 0.0 || rect.h <= 0.0 { return; } let r = (rect.h * 0.075).clamp(1.0, 2.5) as f64; let cx = (rect.x + rect.w * 0.5) as f64; // A dos radios del borde de abajo: pegado al canto se corta con el redondeo // de la celda seleccionada. let cy = (rect.y + rect.h) as f64 - r * 2.2; scene.fill( Fill::NonZero, Affine::IDENTITY, color, None, &Circle::new(Point::new(cx, cy), r), ); } #[cfg(test)] mod tests { use super::*; #[test] fn los_nombres_de_fabrica_son_los_de_siempre_y_se_pueden_cambiar() { let es = NombresCalendario::default(); // El default no le cambia nada a ningún caller. assert_eq!(es.iniciales_desde(WeekStart::Monday), WeekStart::Monday.weekday_initials_es()); assert_eq!(es.iniciales_desde(WeekStart::Sunday), WeekStart::Sunday.weekday_initials_es()); assert_eq!(es.nombre_del_dia(7, 8, 2026), "7 de agosto de 2026"); assert_eq!(es.mes(13), "—"); let en = NombresCalendario { fecha_dia: "%m %d, %y", meses: ["January"; 12], ..es }; assert_eq!(en.nombre_del_dia(7, 8, 2026), "January 7, 2026"); } #[test] fn nav_date_dia_y_semana_cruzan_meses() { let d = NaiveDate::from_ymd_opt(2026, 1, 31).unwrap(); assert_eq!(nav_date(d, CalMove::Day(1)), NaiveDate::from_ymd_opt(2026, 2, 1).unwrap()); assert_eq!(nav_date(d, CalMove::Week(1)), NaiveDate::from_ymd_opt(2026, 2, 7).unwrap()); let ene1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); assert_eq!(nav_date(ene1, CalMove::Day(-1)), NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()); } #[test] fn nav_date_mes_clampa_el_dia_al_destino() { // 31-ene + 1 mes → febrero no tiene 31 → clampa a 28 (2026 no bisiesto). let d = NaiveDate::from_ymd_opt(2026, 1, 31).unwrap(); assert_eq!(nav_date(d, CalMove::Month(1)), NaiveDate::from_ymd_opt(2026, 2, 28).unwrap()); // En bisiesto clampa a 29. let d24 = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(); assert_eq!(nav_date(d24, CalMove::Month(1)), NaiveDate::from_ymd_opt(2024, 2, 29).unwrap()); } #[test] fn nav_date_home_end_saltan_a_los_bordes_del_mes() { let d = NaiveDate::from_ymd_opt(2026, 2, 15).unwrap(); assert_eq!(nav_date(d, CalMove::MonthEdge(false)), NaiveDate::from_ymd_opt(2026, 2, 1).unwrap()); assert_eq!(nav_date(d, CalMove::MonthEdge(true)), NaiveDate::from_ymd_opt(2026, 2, 28).unwrap()); } #[test] fn days_in_month_meses_estandar() { assert_eq!(days_in_month(2026, 1), 31); assert_eq!(days_in_month(2026, 2), 28); assert_eq!(days_in_month(2024, 2), 29); // bisiesto // Los siglos, que es donde la regla se rompe si se escribe de memoria. // Vinieron del calendario que `pata` tenía hecho a mano y que este // widget reemplazó (2026-09-06): la implementación se borró, sus casos // no. assert_eq!(days_in_month(2000, 2), 29); // divisible por 400 → sí assert_eq!(days_in_month(1900, 2), 28); // divisible por 100 y no 400 → no assert_eq!(days_in_month(2026, 4), 30); assert_eq!(days_in_month(2026, 12), 31); // Fuera de rango → 0. assert_eq!(days_in_month(2026, 0), 0); assert_eq!(days_in_month(2026, 13), 0); } #[test] fn first_weekday_index_monday_y_sunday_start() { // 1 de enero 2026 cae JUEVES. // Con Monday-start: Mon=0, Tue=1, Wed=2, Thu=3 → index = 3. assert_eq!(first_weekday_index(2026, 1, WeekStart::Monday), 3); // Con Sunday-start: Sun=0, Mon=1, ..., Thu=4. assert_eq!(first_weekday_index(2026, 1, WeekStart::Sunday), 4); // 1 de febrero 2026 cae DOMINGO. // Monday-start: Sun=6. assert_eq!(first_weekday_index(2026, 2, WeekStart::Monday), 6); // Sunday-start: Sun=0. assert_eq!(first_weekday_index(2026, 2, WeekStart::Sunday), 0); // Junio 2026 arranca LUNES: el caso del índice 0, que es el que se cae // solo cuando alguien reescribe la cuenta con un `+ 1` de más. Venía // del calendario a mano de `pata` (ver `days_in_month_meses_estandar`). assert_eq!(first_weekday_index(2026, 6, WeekStart::Monday), 0); } #[test] fn month_grid_enero_2026_monday_start() { // Enero 2026: 31 días, primer día jueves (index 3 con Monday-start). let g = month_grid(2026, 1, WeekStart::Monday); // Tres celdas vacías al inicio. assert_eq!(g[0][0], None); assert_eq!(g[0][1], None); assert_eq!(g[0][2], None); assert_eq!(g[0][3], Some(1)); // jueves 1 assert_eq!(g[0][6], Some(4)); // domingo 4 assert_eq!(g[1][0], Some(5)); // lunes 5 // Último día: 31. (31 + 3 - 1) / 7 = 33/7 = 4, % 7 = 5 ⇒ fila 4, col 5. assert_eq!(g[4][5], Some(31)); // Fila 5 (sexta) entera vacía. for c in g[5].iter() { assert_eq!(*c, None); } } #[test] fn month_grid_febrero_2026_28_dias_domingo_inicio() { // Febrero 2026: 28 días, primer día domingo (index 6 con Monday-start). let g = month_grid(2026, 2, WeekStart::Monday); // Primera fila: seis vacías + Some(1) en col 6 (domingo). for c in 0..6 { assert_eq!(g[0][c], None); } assert_eq!(g[0][6], Some(1)); assert_eq!(g[1][0], Some(2)); // lunes 2 // 28: (28+6-1)/7 = 33/7 = 4, %7 = 5 ⇒ fila 4 col 5. assert_eq!(g[4][5], Some(28)); // Fila 5 entera vacía. for c in g[5].iter() { assert_eq!(*c, None); } } #[test] fn shift_month_wrappea_anios() { // Enero → diciembre del año previo. assert_eq!(shift_month(2026, 1, -1), (2025, 12)); // Diciembre → enero del año siguiente. assert_eq!(shift_month(2026, 12, 1), (2027, 1)); // Salto de varios meses. assert_eq!(shift_month(2026, 6, -8), (2025, 10)); assert_eq!(shift_month(2026, 6, 18), (2027, 12)); } #[test] fn week_start_index_consistente() { // Monday-start. assert_eq!(WeekStart::Monday.index(Weekday::Mon), 0); assert_eq!(WeekStart::Monday.index(Weekday::Sun), 6); // Sunday-start. assert_eq!(WeekStart::Sunday.index(Weekday::Sun), 0); assert_eq!(WeekStart::Sunday.index(Weekday::Mon), 1); assert_eq!(WeekStart::Sunday.index(Weekday::Sat), 6); } fn f(y: i32, m: u32, d: u32) -> NaiveDate { NaiveDate::from_ymd_opt(y, m, d).expect("fecha de test válida") } #[test] fn nav_date_ano_clampa_el_dia_como_el_mes() { // 29-feb de un bisiesto + 1 año no existe → 28-feb. assert_eq!(nav_date(f(2024, 2, 29), CalMove::Year(1)), f(2025, 2, 28)); // Y hacia atrás, a un bisiesto, el día se conserva. assert_eq!(nav_date(f(2025, 3, 15), CalMove::Year(-1)), f(2024, 3, 15)); // Un año es doce meses, literalmente. assert_eq!( nav_date(f(2026, 8, 4), CalMove::Year(2)), nav_date(f(2026, 8, 4), CalMove::Month(24)) ); } #[test] fn el_mapa_de_teclas_es_el_del_picker_nativo() { use llimphi_compositor::{Key, KeyEvent, KeyState, Modifiers, NamedKey}; let tecla = |k: NamedKey, ctrl: bool| KeyEvent { key: Key::Named(k), state: KeyState::Pressed, text: None, modifiers: Modifiers { ctrl, ..Default::default() }, repeat: false, }; assert_eq!(cal_move_desde_tecla(&tecla(NamedKey::ArrowLeft, false)), Some(CalMove::Day(-1))); assert_eq!(cal_move_desde_tecla(&tecla(NamedKey::ArrowDown, false)), Some(CalMove::Week(1))); assert_eq!(cal_move_desde_tecla(&tecla(NamedKey::PageUp, false)), Some(CalMove::Month(-1))); assert_eq!(cal_move_desde_tecla(&tecla(NamedKey::Home, false)), Some(CalMove::MonthEdge(false))); // Ctrl asciende el salto de mes a salto de año. assert_eq!(cal_move_desde_tecla(&tecla(NamedKey::PageUp, true)), Some(CalMove::Year(-1))); assert_eq!(cal_move_desde_tecla(&tecla(NamedKey::PageDown, true)), Some(CalMove::Year(1))); // Ctrl sobre una flecha NO cambia el movimiento (no inventamos gestos). assert_eq!(cal_move_desde_tecla(&tecla(NamedKey::ArrowLeft, true)), Some(CalMove::Day(-1))); // Lo que no es del mapa no dispara nada, y el release tampoco. assert_eq!(cal_move_desde_tecla(&tecla(NamedKey::Escape, false)), None); let suelta = KeyEvent { state: KeyState::Released, ..tecla(NamedKey::ArrowLeft, false) }; assert_eq!(cal_move_desde_tecla(&suelta), None); } #[test] fn limites_permiten_los_bordes_y_clampean_afuera() { let lim = Limites::entre(f(2026, 8, 10), f(2026, 8, 20)); // Inclusive de los dos lados. assert!(lim.permite(f(2026, 8, 10))); assert!(lim.permite(f(2026, 8, 20))); assert!(!lim.permite(f(2026, 8, 9))); assert!(!lim.permite(f(2026, 8, 21))); // El clamp lleva al borde más cercano, no al otro. assert_eq!(lim.clamp(f(2026, 1, 1)), f(2026, 8, 10)); assert_eq!(lim.clamp(f(2026, 12, 31)), f(2026, 8, 20)); assert_eq!(lim.clamp(f(2026, 8, 15)), f(2026, 8, 15)); // Un lado suelto no limita el otro. assert!(Limites::desde(f(2026, 8, 10)).permite(f(2030, 1, 1))); assert!(Limites::hasta(f(2026, 8, 10)).permite(f(1990, 1, 1))); // Sin límites, todo pasa. assert!(Limites::default().permite(f(1, 1, 1))); } #[test] fn mes_habil_mira_el_cruce_del_tramo_no_el_dia_1() { // Límite a mitad de agosto: agosto ES hábil (tiene días de los dos // lados), julio no, septiembre sí. let lim = Limites::desde(f(2026, 8, 15)); assert!(lim.mes_habil(2026, 8)); assert!(!lim.mes_habil(2026, 7)); assert!(lim.mes_habil(2026, 9)); // El caso que delata mirar sólo el día 1: el mínimo es el ÚLTIMO día // del mes. El mes sigue siendo hábil por ese día. let borde = Limites::desde(f(2026, 8, 31)); assert!(borde.mes_habil(2026, 8)); assert!(!borde.mes_habil(2026, 7)); // Mes inválido nunca es hábil. assert!(!Limites::default().mes_habil(2026, 13)); } #[test] fn nav_limitada_aterriza_en_el_borde_en_vez_de_salirse() { let lim = Limites::entre(f(2026, 8, 10), f(2026, 8, 20)); // Una semana atrás desde el 12 se pasaría al 5 → queda en el mínimo. assert_eq!(nav_date_limitada(f(2026, 8, 12), CalMove::Week(-1), &lim), f(2026, 8, 10)); // Un mes adelante se iría a septiembre → queda en el máximo. assert_eq!(nav_date_limitada(f(2026, 8, 15), CalMove::Month(1), &lim), f(2026, 8, 20)); // Adentro, se comporta igual que `nav_date`. assert_eq!(nav_date_limitada(f(2026, 8, 15), CalMove::Day(1), &lim), f(2026, 8, 16)); } #[test] fn el_rango_se_arma_en_tres_clicks_y_se_ordena_solo() { let r = RangoSeleccion::default(); // 1º click: medio rango, sin fin todavía. let r = r.click(f(2026, 8, 10)); assert_eq!(r.inicio, Some(f(2026, 8, 10))); assert_eq!(r.fin, None); assert_eq!(r.dias(), 0, "a medio hacer no abarca días"); assert!(r.contiene(f(2026, 8, 10)), "el extremo ya elegido cuenta"); assert!(!r.contiene(f(2026, 8, 11))); // 2º click ANTES del inicio: se ordena solo (corregirse hacia atrás es // legítimo, no debería reiniciar). let r = r.click(f(2026, 8, 5)); assert_eq!(r.normalizado(), Some((f(2026, 8, 5), f(2026, 8, 10)))); assert_eq!(r.dias(), 6); assert!(r.contiene(f(2026, 8, 7))); assert!(r.es_extremo(f(2026, 8, 5)) && r.es_extremo(f(2026, 8, 10))); assert!(!r.es_extremo(f(2026, 8, 7))); // 3º click: empieza uno nuevo. let r = r.click(f(2026, 9, 1)); assert_eq!(r.inicio, Some(f(2026, 9, 1))); assert_eq!(r.fin, None); } #[test] fn dos_clicks_al_mismo_dia_dan_un_rango_de_un_dia() { // Una reserva de una noche es una elección legítima, no un rango vacío. let r = RangoSeleccion::default().click(f(2026, 8, 10)).click(f(2026, 8, 10)); assert_eq!(r.normalizado(), Some((f(2026, 8, 10), f(2026, 8, 10)))); assert_eq!(r.dias(), 1); assert_eq!(RangoSeleccion::dia(f(2026, 8, 10)), r); } #[test] fn el_rango_se_recorta_a_los_limites_o_se_cae() { let lim = Limites::entre(f(2026, 8, 10), f(2026, 8, 20)); // Desborda por izquierda: se recorta al mínimo. let r = RangoSeleccion { inicio: Some(f(2026, 8, 1)), fin: Some(f(2026, 8, 15)) }; assert_eq!( r.recortado(&lim).and_then(|r| r.normalizado()), Some((f(2026, 8, 10), f(2026, 8, 15))) ); // Entero afuera: no queda nada que guardar. let fuera = RangoSeleccion { inicio: Some(f(2026, 1, 1)), fin: Some(f(2026, 1, 5)) }; assert_eq!(fuera.recortado(&lim), None); // A medio hacer no se recorta (no hay rango todavía). assert_eq!(RangoSeleccion::default().click(f(2026, 8, 15)).recortado(&lim), None); } /// **El punto no mueve nada.** Va pintado y no como hijo del nodo: si fuera /// un hijo, la celda con marca sería más alta que las otras y la grilla se /// desalinearía sola los meses con eventos. Se mide el alto con y sin marcas. #[test] fn marcar_dias_no_corre_la_grilla() { use llimphi_compositor::measured_height; let theme = llimphi_theme::Theme::default(); let spec = |marcados: alloc::vec::Vec| CalendarSpec::<()> { view_year: 2026, view_month: 9, selected: NaiveDate::from_ymd_opt(2026, 9, 7), today: NaiveDate::from_ymd_opt(2026, 9, 7), week_start: WeekStart::Monday, palette: CalendarPalette::from_theme(&theme), on_select: alloc::sync::Arc::new(|_| ()), on_view_change: alloc::sync::Arc::new(|_, _| ()), marcados, }; let sin = measured_height(calendar_view(spec(alloc::vec::Vec::new())), 240.0); let con = measured_height( calendar_view(spec( (1..=28) .filter_map(|d| NaiveDate::from_ymd_opt(2026, 9, d)) .collect(), )), 240.0, ); assert_eq!(sin, con, "el mes entero marcado no puede cambiar el alto"); assert!(sin > 0.0); } /// Un día marcado que **no está en el mes en foco** no rompe nada: el /// llamador pide el mes entero y puede pasarse de largo. #[test] fn una_marca_de_otro_mes_se_ignora() { use llimphi_compositor::measured_height; let theme = llimphi_theme::Theme::default(); let spec = CalendarSpec::<()> { view_year: 2026, view_month: 9, selected: None, today: None, week_start: WeekStart::Monday, palette: CalendarPalette::from_theme(&theme), on_select: alloc::sync::Arc::new(|_| ()), on_view_change: alloc::sync::Arc::new(|_, _| ()), marcados: alloc::vec![NaiveDate::from_ymd_opt(2026, 3, 1).unwrap()], }; assert!(measured_height(calendar_view(spec), 240.0) > 0.0); } }