tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Sutichaykuna

⎇ main widgets/datetime-picker/src/lib.rs Rust · 952 siq'ikuna · 34.6 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

1//! `llimphi-widget-datetime-picker` — pickers de **fecha** y **hora**.
2//!
3//! Los dos tienen la misma forma, que es la de cualquier picker nativo:
4//!
5//! 1. un **disparador** en el formulario, que muestra el valor formateado (o el
6//!    placeholder si no hay) y emite un `Msg` al clickearlo;
7//! 2. un **popup** que el host monta en `App::view_overlay`, con scrim que
8//!    cierra al click-fuera.
9//!
10//! El widget **no guarda estado**: el valor, si está abierto y —para la fecha—
11//! el mes en foco viven en el `Model` del caller. Eso es lo que permite que el
12//! mismo picker sirva para un formulario, una celda de tabla o un filtro.
13//!
14//! El de fecha **compone [`llimphi_widget_calendar`]**, no reimplementa una
15//! grilla: hereda de ahí los límites min/max, la selección de rango, el salto de
16//! año y el mapa de teclas. El de hora es una grilla de horas + una de minutos
17//! por paso; sin scroll y sin estado extra, que es lo que un picker compacto
18//! necesita (una columna scrolleable pediría offset propio en el `Model` para
19//! algo que se elige de un vistazo).
20//!
21//! Lo que sí es de este crate: **formatear y parsear**, que es donde un picker
22//! se vuelve usable o no. Las dos cosas son puras y testeables.
23//!
24//! ```ignore
25//! // en la vista del formulario:
26//! date_picker_trigger(DateTrigger {
27//!     valor: model.fecha, formato: FormatoFecha::DiaMesAnio,
28//!     placeholder: "Elegí una fecha", enabled: true,
29//!     on_open: Msg::AbrirFecha, palette: PickerPalette::from_theme(&theme),
30//! })
31//! // en view_overlay, si model.fecha_abierta:
32//! date_picker_popup(DatePopup {
33//!     anchor: model.ancla, viewport: (w, h),
34//!     valor: model.fecha, view: (model.anio, model.mes), today: Some(hoy),
35//!     opciones: CalendarOpciones { limites, ..Default::default() },
36//!     on_pick: Arc::new(Msg::ElegirFecha), on_view_change: Arc::new(Msg::VerMes),
37//!     on_dismiss: Msg::CerrarFecha, acciones: Acciones::hoy_y_limpiar(),
38//!     palette, week_start: WeekStart::Monday,
39//! })
40//! ```
41
42#![forbid(unsafe_code)]
43// Sin `std`: sólo `alloc`, para que cruce a una jaula WASM o al kernel (W4.6).
44#![no_std]
45
46extern crate alloc;
47
48use alloc::format;
49use alloc::string::{String, ToString};
50use alloc::sync::Arc;
51use alloc::vec;
52use alloc::vec::Vec;
53
54use chrono::{Datelike, NaiveDate};
55use llimphi_compositor::llimphi_layout::taffy::{
56    prelude::{length, percent, FlexDirection, Position, Size, Style},
57    AlignItems, JustifyContent, Rect,
58};
59use llimphi_compositor::llimphi_text::Alignment;
60use llimphi_compositor::peniko::Color;
61use llimphi_compositor::View;
62use llimphi_theme::Theme;
63use llimphi_widget_calendar::{
64    calendar_view_ext, CalendarOpciones, CalendarPalette, CalendarSpec, WeekStart,
65};
66
67// ─────────────────────────────────────────────────────────────────────────
68// Formato y parseo — donde un picker se vuelve usable o no
69// ─────────────────────────────────────────────────────────────────────────
70
71/// Cómo se escribe una fecha en el disparador.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum FormatoFecha {
74    /// `2026-08-04` — ordenable como texto, sin ambigüedad de región.
75    Iso,
76    /// `04/08/2026` — el default de la mayor parte del mundo hispanohablante.
77    #[default]
78    DiaMesAnio,
79    /// `4 de agosto de 2026` — para cuando hay lugar y se lee, no se compara.
80    Largo,
81}
82
83/// Nombre del mes en español (1..=12).
84fn mes_es(m: u32) -> &'static str {
85    [
86        "enero",
87        "febrero",
88        "marzo",
89        "abril",
90        "mayo",
91        "junio",
92        "julio",
93        "agosto",
94        "septiembre",
95        "octubre",
96        "noviembre",
97        "diciembre",
98    ]
99    .get((m as usize).wrapping_sub(1))
100    .copied()
101    .unwrap_or("—")
102}
103
104/// Escribe la fecha en el formato pedido.
105pub fn formatear_fecha(d: NaiveDate, f: FormatoFecha) -> String {
106    match f {
107        FormatoFecha::Iso => format!("{:04}-{:02}-{:02}", d.year(), d.month(), d.day()),
108        FormatoFecha::DiaMesAnio => format!("{:02}/{:02}/{:04}", d.day(), d.month(), d.year()),
109        FormatoFecha::Largo => format!("{} de {} de {}", d.day(), mes_es(d.month()), d.year()),
110    }
111}
112
113/// Lee una fecha tipeada a mano. Acepta las dos formas que la gente escribe
114/// —`2026-08-04` y `4/8/2026`— con `-`, `/` o `.` de separador, y con o sin
115/// ceros a la izquierda.
116///
117/// **Por qué existe**, si el picker ya tiene calendario: un picker que sólo se
118/// puede clickear obliga a navegar doce meses para poner una fecha de
119/// nacimiento. El campo tipeable es la mitad rápida del control, y sin parseo
120/// no hay campo tipeable.
121///
122/// Distingue las dos formas por dónde está el año: si el **primer** número
123/// tiene cuatro dígitos es ISO, si no es día-primero. Devuelve `None` si no
124/// hay tres números o si la fecha no existe (31 de febrero).
125pub fn parsear_fecha(texto: &str) -> Option<NaiveDate> {
126    let partes: Vec<&str> = texto
127        .trim()
128        .split(['-', '/', '.'])
129        .map(|p| p.trim())
130        .filter(|p| !p.is_empty())
131        .collect();
132    if partes.len() != 3 {
133        return None;
134    }
135    let n: Vec<i32> = partes.iter().filter_map(|p| p.parse::<i32>().ok()).collect();
136    if n.len() != 3 {
137        return None;
138    }
139    // El año se reconoce por el **ancho** del campo tal como se escribió: cuatro
140    // dígitos adelante es ISO, cuatro atrás es día-primero. Un año de dos
141    // dígitos NO se completa a mano —"26" podría ser 2026, 1926 o el año 26— y
142    // adivinar en una fecha es peor que rechazarla: se devuelve `None` y el
143    // campo queda en rojo, que es información honesta.
144    let (y, m, d) = if partes[0].len() == 4 {
145        (n[0], n[1], n[2])
146    } else if partes[2].len() == 4 {
147        (n[2], n[1], n[0])
148    } else {
149        return None;
150    };
151    if !(1..=12).contains(&m) || d < 1 {
152        return None;
153    }
154    NaiveDate::from_ymd_opt(y, m as u32, d as u32)
155}
156
157/// Una hora del día, sin fecha ni zona: lo que elige un `<input type=time>`.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
159pub struct Hora {
160    pub h: u8,
161    pub m: u8,
162}
163
164/// Reloj de 24 horas o de 12 con AM/PM.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
166pub enum FormatoHora {
167    #[default]
168    H24,
169    H12,
170}
171
172/// Cuál de los dos campos de la hora mueve una tecla.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum CampoHora {
175    Hora,
176    Minuto,
177}
178
179impl Hora {
180    /// Construye validando; `None` si no es una hora del día.
181    pub fn nueva(h: u8, m: u8) -> Option<Self> {
182        (h < 24 && m < 60).then_some(Self { h, m })
183    }
184
185    /// Construye envolviendo: 25:70 es 02:10 del día siguiente (la hora sola no
186    /// lleva la cuenta de los días). Es lo que necesita mover con flechas sin
187    /// tener que clampear en cada paso.
188    pub fn envolviendo(h: i32, m: i32) -> Self {
189        let total = (h * 60 + m).rem_euclid(24 * 60);
190        Self { h: (total / 60) as u8, m: (total % 60) as u8 }
191    }
192
193    /// Minutos desde medianoche.
194    pub fn minutos(self) -> u32 {
195        self.h as u32 * 60 + self.m as u32
196    }
197
198    /// La misma hora en 12 h: `(1..=12, es_pm)`. Medianoche es 12 AM y mediodía
199    /// 12 PM — los dos casos que todo el mundo se equivoca escribiendo esto a
200    /// mano con un `% 12` pelado, que da 0.
201    pub fn en_12(self) -> (u8, bool) {
202        let pm = self.h >= 12;
203        let h12 = match self.h % 12 {
204            0 => 12,
205            otro => otro,
206        };
207        (h12, pm)
208    }
209
210    /// Desde 12 h: `(1..=12, es_pm)` → hora de 24.
211    pub fn desde_12(h12: u8, pm: bool, m: u8) -> Option<Self> {
212        if !(1..=12).contains(&h12) {
213            return None;
214        }
215        let base = if h12 == 12 { 0 } else { h12 };
216        Self::nueva(base + if pm { 12 } else { 0 }, m)
217    }
218
219    /// `14:05` o `2:05 PM`.
220    pub fn formatear(self, f: FormatoHora) -> String {
221        match f {
222            FormatoHora::H24 => format!("{:02}:{:02}", self.h, self.m),
223            FormatoHora::H12 => {
224                let (h12, pm) = self.en_12();
225                format!("{}:{:02} {}", h12, self.m, if pm { "PM" } else { "AM" })
226            }
227        }
228    }
229
230    /// Lee una hora tipeada: `14:05`, `14.05`, `2:05 pm`, `2 pm`, `1405`.
231    /// Devuelve `None` si no es una hora del día.
232    pub fn parsear(texto: &str) -> Option<Self> {
233        let t = texto.trim().to_lowercase();
234        let pm = t.ends_with("pm");
235        let am = t.ends_with("am");
236        let cuerpo = if pm || am { t[..t.len() - 2].trim() } else { t.trim() };
237        let (h, m) = match cuerpo.split_once([':', '.', 'h']) {
238            Some((a, b)) => {
239                let b = b.trim();
240                (
241                    a.trim().parse::<i32>().ok()?,
242                    if b.is_empty() { 0 } else { b.parse::<i32>().ok()? },
243                )
244            }
245            // Sin separador: `1405` son cuatro dígitos hora+minuto; `9` es la
246            // hora sola. Cualquier otro largo es basura y se rechaza en vez de
247            // inventarle un corte.
248            None => {
249                let d = cuerpo.trim();
250                match d.len() {
251                    4 => (d[..2].parse().ok()?, d[2..].parse().ok()?),
252                    1 | 2 => (d.parse().ok()?, 0),
253                    _ => return None,
254                }
255            }
256        };
257        if !(0..60).contains(&m) {
258            return None;
259        }
260        if pm || am {
261            Hora::desde_12(u8::try_from(h).ok()?, pm, m as u8)
262        } else {
263            Hora::nueva(u8::try_from(h).ok()?, m as u8)
264        }
265    }
266
267    /// Redondea los minutos **hacia abajo** al múltiplo de `paso` (5, 15, 30…),
268    /// que es lo que hace falta cuando el picker sólo ofrece esos minutos y el
269    /// valor vino de otro lado.
270    pub fn al_paso(self, paso: u8) -> Self {
271        if paso == 0 {
272            return self;
273        }
274        Self { h: self.h, m: self.m - self.m % paso }
275    }
276
277    /// Mueve un campo con flechas, envolviendo. El minuto se mueve **de a
278    /// `paso`**: con paso 5, ↑ sobre los minutos va de :10 a :15, no a :11.
279    pub fn mover(self, campo: CampoHora, delta: i32, paso: u8) -> Self {
280        let paso = paso.max(1) as i32;
281        match campo {
282            CampoHora::Hora => Self::envolviendo(self.h as i32 + delta, self.m as i32),
283            CampoHora::Minuto => Self::envolviendo(self.h as i32, self.m as i32 + delta * paso),
284        }
285    }
286}
287
288// ─────────────────────────────────────────────────────────────────────────
289// Paleta y geometría
290// ─────────────────────────────────────────────────────────────────────────
291
292/// Paleta compartida por el disparador y el popup.
293#[derive(Debug, Clone, Copy)]
294pub struct PickerPalette {
295    pub bg_campo: Color,
296    pub bg_panel: Color,
297    pub bg_hover: Color,
298    pub bg_elegido: Color,
299    pub fg_texto: Color,
300    pub fg_elegido: Color,
301    pub fg_placeholder: Color,
302    pub borde: Color,
303    pub scrim: Color,
304    pub accent: Color,
305}
306
307impl PickerPalette {
308    pub fn from_theme(t: &Theme) -> Self {
309        Self {
310            bg_campo: t.bg_input,
311            bg_panel: t.bg_panel,
312            bg_hover: t.bg_row_hover,
313            bg_elegido: t.accent,
314            fg_texto: t.fg_text,
315            fg_elegido: t.bg_app,
316            fg_placeholder: t.fg_placeholder,
317            borde: t.border,
318            scrim: Color::from_rgba8(0, 0, 0, 64),
319            accent: t.accent,
320        }
321    }
322
323    fn calendario(&self, t: &Theme) -> CalendarPalette {
324        CalendarPalette::from_theme(t)
325    }
326}
327
328impl Default for PickerPalette {
329    fn default() -> Self {
330        Self::from_theme(&Theme::dark())
331    }
332}
333
334/// Alto del disparador (igual que un `text-input` de una línea).
335const CAMPO_H: f32 = 30.0;
336/// Ancho del popup del calendario — el del `calendar` más su marco.
337const POPUP_FECHA_W: f32 = 7.0 * 32.0 + 8.0 * 2.0;
338/// Celda de la grilla de horas.
339const CELDA_W: f32 = 40.0;
340const CELDA_H: f32 = 26.0;
341/// Horas por fila en la grilla del picker de hora.
342const HORAS_POR_FILA: usize = 6;
343const PANEL_PAD: f32 = 8.0;
344const MARGEN: f32 = 4.0;
345
346/// Clampa `(x, y)` para que un panel de `(w, h)` entre en el viewport. Misma
347/// regla que el `context-menu`: pegado al ancla salvo que se salga.
348fn posicion(anchor: (f32, f32), viewport: (f32, f32), w: f32, h: f32) -> (f32, f32) {
349    let x = anchor.0.min((viewport.0 - w - MARGEN).max(MARGEN)).max(MARGEN);
350    let y = anchor.1.min((viewport.1 - h - MARGEN).max(MARGEN)).max(MARGEN);
351    (x, y)
352}
353
354// ─────────────────────────────────────────────────────────────────────────
355// Disparadores
356// ─────────────────────────────────────────────────────────────────────────
357
358/// Disparador del picker de fecha: el control que va en el formulario.
359pub struct DateTrigger<Msg> {
360    pub valor: Option<NaiveDate>,
361    pub formato: FormatoFecha,
362    /// Qué decir cuando no hay valor.
363    pub placeholder: String,
364    pub enabled: bool,
365    /// Click sobre el campo → el host abre el popup y guarda el ancla.
366    pub on_open: Msg,
367    pub palette: PickerPalette,
368}
369
370/// Disparador del picker de hora.
371pub struct TimeTrigger<Msg> {
372    pub valor: Option<Hora>,
373    pub formato: FormatoHora,
374    pub placeholder: String,
375    pub enabled: bool,
376    pub on_open: Msg,
377    pub palette: PickerPalette,
378}
379
380/// El campo con el valor formateado y su ícono. Común a los dos pickers: lo
381/// único que cambia es el texto y el glifo.
382fn disparador<Msg: Clone + 'static>(
383    texto: Option<String>,
384    placeholder: String,
385    glifo: &'static str,
386    etiqueta: &'static str,
387    enabled: bool,
388    on_open: Msg,
389    p: &PickerPalette,
390) -> View<Msg> {
391    let hay_valor = texto.is_some();
392    let fg = if !enabled {
393        p.fg_placeholder
394    } else if hay_valor {
395        p.fg_texto
396    } else {
397        p.fg_placeholder
398    };
399    let contenido = View::<Msg>::new(Style {
400        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
401        flex_grow: 1.0,
402        align_items: Some(AlignItems::CENTER),
403        ..Default::default()
404    })
405    .text_aligned(texto.unwrap_or(placeholder), 12.5, fg, Alignment::Start)
406    .ellipsis(1);
407    let icono = View::<Msg>::new(Style {
408        size: Size { width: length(20.0_f32), height: percent(1.0_f32) },
409        flex_shrink: 0.0,
410        align_items: Some(AlignItems::CENTER),
411        justify_content: Some(JustifyContent::CENTER),
412        ..Default::default()
413    })
414    .text(glifo, 13.0, if enabled { p.accent } else { p.fg_placeholder });
415
416    let mut v = View::<Msg>::new(Style {
417        flex_direction: FlexDirection::Row,
418        size: Size { width: percent(1.0_f32), height: length(CAMPO_H) },
419        align_items: Some(AlignItems::CENTER),
420        padding: Rect {
421            left: length(10.0_f32),
422            right: length(6.0_f32),
423            top: length(0.0_f32),
424            bottom: length(0.0_f32),
425        },
426        ..Default::default()
427    })
428    .fill(p.bg_campo)
429    .radius(6.0)
430    .border(1.0, p.borde)
431    // El nombre accesible dice QUÉ elige, no qué dice ahora: el valor ya viaja
432    // como texto del nodo.
433    .role(llimphi_compositor::Role::Button)
434    .aria_label(etiqueta)
435    .aria_disabled(!enabled)
436    .children(vec![contenido, icono]);
437    if enabled {
438        v = v
439            .hover_fill(p.bg_hover)
440            .cursor(llimphi_compositor::Cursor::Pointer)
441            .on_click(on_open);
442    }
443    v
444}
445
446/// El campo de fecha del formulario. El popup lo monta el host aparte, en su
447/// `view_overlay` — acá sólo se dispara.
448pub fn date_picker_trigger<Msg: Clone + 'static>(t: DateTrigger<Msg>) -> View<Msg> {
449    let DateTrigger { valor, formato, placeholder, enabled, on_open, palette } = t;
450    disparador(
451        valor.map(|d| formatear_fecha(d, formato)),
452        placeholder,
453        "▦",
454        "Elegir fecha",
455        enabled,
456        on_open,
457        &palette,
458    )
459}
460
461/// El campo de hora del formulario.
462pub fn time_picker_trigger<Msg: Clone + 'static>(t: TimeTrigger<Msg>) -> View<Msg> {
463    let TimeTrigger { valor, formato, placeholder, enabled, on_open, palette } = t;
464    disparador(
465        valor.map(|h| h.formatear(formato)),
466        placeholder,
467        "◷",
468        "Elegir hora",
469        enabled,
470        on_open,
471        &palette,
472    )
473}
474
475// ─────────────────────────────────────────────────────────────────────────
476// Popups
477// ─────────────────────────────────────────────────────────────────────────
478
479/// Botones de acción al pie del popup de fecha.
480#[derive(Debug, Clone, Copy, Default)]
481pub struct Acciones {
482    /// «Hoy» — salta al día de hoy y lo elige.
483    pub hoy: bool,
484    /// «Limpiar» — deja el campo sin valor.
485    pub limpiar: bool,
486}
487
488impl Acciones {
489    pub fn hoy_y_limpiar() -> Self {
490        Self { hoy: true, limpiar: true }
491    }
492}
493
494/// Popup del picker de fecha. Se monta en `App::view_overlay`.
495pub struct DatePopup<Msg> {
496    /// Esquina donde se ancla (típicamente el pie del disparador, en
497    /// coordenadas de pantalla).
498    pub anchor: (f32, f32),
499    pub viewport: (f32, f32),
500    pub valor: Option<NaiveDate>,
501    /// Mes en foco `(año, mes)` — lo guarda el host, así navegar no cambia el
502    /// valor elegido.
503    pub view: (i32, u32),
504    pub today: Option<NaiveDate>,
505    pub week_start: WeekStart,
506    /// Límites, rango y salto de año: todo lo que ya sabe el `calendar`.
507    pub opciones: CalendarOpciones,
508    pub on_pick: Arc<dyn Fn(NaiveDate) -> Msg + Send + Sync>,
509    pub on_view_change: Arc<dyn Fn(i32, u32) -> Msg + Send + Sync>,
510    /// Click-fuera (scrim) o Esc.
511    pub on_dismiss: Msg,
512    /// «Limpiar» — sólo si `acciones.limpiar`.
513    pub on_clear: Option<Msg>,
514    pub acciones: Acciones,
515    pub palette: PickerPalette,
516    /// El theme con el que pintar el calendario de adentro (su paleta es suya).
517    pub theme: Theme,
518}
519
520/// Scrim + panel, la envoltura común de los dos popups.
521fn envoltura<Msg: Clone + 'static>(
522    panel: View<Msg>,
523    x: f32,
524    y: f32,
525    on_dismiss: Msg,
526    p: &PickerPalette,
527) -> View<Msg> {
528    let panel = View::<Msg>::new(Style {
529        position: Position::Absolute,
530        inset: Rect {
531            left: length(x),
532            top: length(y),
533            right: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
534            bottom: llimphi_compositor::llimphi_layout::taffy::prelude::auto(),
535        },
536        ..Default::default()
537    })
538    .children(vec![panel]);
539    // Scrim full-screen por TAMAÑO (`percent(1)`), no por `inset: 0` absoluto:
540    // el overlay lo monta el host como raíz de su árbol, y un absoluto sin
541    // ancestro posicionado colapsa a cero — se veía clickeable pero medía nada.
542    // Es el mismo estilo que el scrim del `context-menu`.
543    View::<Msg>::new(Style {
544        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
545        ..Default::default()
546    })
547    .fill(p.scrim)
548    // El scrim cierra al click-fuera. Es el mismo contrato del `context-menu`:
549    // sin esto el popup queda abierto para siempre si el host se olvida del Esc.
550    .on_click(on_dismiss)
551    .children(vec![panel])
552}
553
554/// Un botón de acción del pie (Hoy / Limpiar).
555fn boton_pie<Msg: Clone + 'static>(texto: &str, msg: Msg, p: &PickerPalette) -> View<Msg> {
556    View::<Msg>::new(Style {
557        size: Size { width: llimphi_compositor::llimphi_layout::taffy::prelude::auto(), height: length(24.0_f32) },
558        align_items: Some(AlignItems::CENTER),
559        justify_content: Some(JustifyContent::CENTER),
560        padding: Rect {
561            left: length(10.0_f32),
562            right: length(10.0_f32),
563            top: length(0.0_f32),
564            bottom: length(0.0_f32),
565        },
566        ..Default::default()
567    })
568    .text(texto.to_string(), 11.5, p.accent)
569    .radius(5.0)
570    .hover_fill(p.bg_hover)
571    .cursor(llimphi_compositor::Cursor::Pointer)
572    .role(llimphi_compositor::Role::Button)
573    .aria_label(texto.to_string())
574    .on_click(msg)
575}
576
577/// Monta el popup del calendario. Devuelve el árbol completo (scrim incluido)
578/// para colgar de `App::view_overlay`.
579pub fn date_picker_popup<Msg: Clone + 'static>(spec: DatePopup<Msg>) -> View<Msg> {
580    let DatePopup {
581        anchor,
582        viewport,
583        valor,
584        view,
585        today,
586        week_start,
587        opciones,
588        on_pick,
589        on_view_change,
590        on_dismiss,
591        on_clear,
592        acciones,
593        palette,
594        theme,
595    } = spec;
596
597    let cal = calendar_view_ext(
598        CalendarSpec {
599            // El selector de fecha no sabe de eventos: es el que elige una
600            // fecha, no el que muestra la agenda.
601            marcados: alloc::vec::Vec::new(),
602            view_year: view.0,
603            view_month: view.1,
604            selected: valor,
605            today,
606            week_start,
607            palette: palette.calendario(&theme),
608            on_select: on_pick.clone(),
609            on_view_change,
610        },
611        opciones,
612    );
613
614    let mut hijos = vec![cal];
615    let mut pie_h = 0.0;
616    if acciones.hoy || acciones.limpiar {
617        let mut botones: Vec<View<Msg>> = Vec::with_capacity(2);
618        if acciones.hoy {
619            // «Hoy» sólo tiene sentido si el host inyectó qué día es hoy: el
620            // widget no toca el reloj (misma doctrina que el calendar).
621            if let Some(h) = today {
622                botones.push(boton_pie("Hoy", (on_pick)(h), &palette));
623            }
624        }
625        if let (true, Some(msg)) = (acciones.limpiar, on_clear) {
626            botones.push(boton_pie("Limpiar", msg, &palette));
627        }
628        if !botones.is_empty() {
629            pie_h = 32.0;
630            hijos.push(
631                View::<Msg>::new(Style {
632                    flex_direction: FlexDirection::Row,
633                    size: Size { width: percent(1.0_f32), height: length(pie_h) },
634                    align_items: Some(AlignItems::CENTER),
635                    justify_content: Some(JustifyContent::END),
636                    gap: Size { width: length(6.0_f32), height: length(0.0_f32) },
637                    ..Default::default()
638                })
639                .children(botones),
640            );
641        }
642    }
643
644    // Alto del calendario (header + iniciales + 6 filas + padding) según si
645    // lleva la fila de año, más el pie.
646    let filas_header = if opciones.navegacion_anual { 2.0 } else { 1.0 };
647    let alto = 32.0 * filas_header + 32.0 * 0.75 + 32.0 * 6.0 + 8.0 * 2.0 + pie_h;
648    let (x, y) = posicion(anchor, viewport, POPUP_FECHA_W, alto);
649
650    let panel = View::<Msg>::new(Style {
651        flex_direction: FlexDirection::Column,
652        size: Size { width: length(POPUP_FECHA_W), height: length(alto) },
653        ..Default::default()
654    })
655    .fill(palette.bg_panel)
656    .radius(8.0)
657    .border(1.0, palette.borde)
658    .children(hijos);
659
660    envoltura(panel, x, y, on_dismiss, &palette)
661}
662
663/// Popup del picker de hora: grilla de horas + grilla de minutos por paso.
664pub struct TimePopup<Msg> {
665    pub anchor: (f32, f32),
666    pub viewport: (f32, f32),
667    pub valor: Option<Hora>,
668    pub formato: FormatoHora,
669    /// Paso de los minutos ofrecidos (5 = :00 :05 :10 …). `0` o `1` ofrece los
670    /// sesenta, que es una grilla enorme: el default sensato es 5.
671    pub paso_minutos: u8,
672    pub on_pick: Arc<dyn Fn(Hora) -> Msg + Send + Sync>,
673    pub on_dismiss: Msg,
674    pub palette: PickerPalette,
675}
676
677/// Los minutos que ofrece la grilla para un paso dado.
678pub fn minutos_ofrecidos(paso: u8) -> Vec<u8> {
679    let paso = paso.max(1);
680    (0..60).step_by(paso as usize).collect()
681}
682
683/// Celda de la grilla (hora o minuto).
684fn celda<Msg: Clone + 'static>(
685    texto: String,
686    etiqueta: String,
687    elegida: bool,
688    msg: Msg,
689    p: &PickerPalette,
690) -> View<Msg> {
691    let mut v = View::<Msg>::new(Style {
692        size: Size { width: length(CELDA_W), height: length(CELDA_H) },
693        flex_shrink: 0.0,
694        align_items: Some(AlignItems::CENTER),
695        justify_content: Some(JustifyContent::CENTER),
696        ..Default::default()
697    })
698    .text(texto, 12.0, if elegida { p.fg_elegido } else { p.fg_texto })
699    .radius(5.0)
700    .cursor(llimphi_compositor::Cursor::Pointer)
701    .role(llimphi_compositor::Role::Button)
702    .aria_label(etiqueta)
703    .aria_checked(elegida)
704    .on_click(msg);
705    if elegida {
706        v = v.fill(p.bg_elegido);
707    } else {
708        v = v.hover_fill(p.bg_hover);
709    }
710    v
711}
712
713/// Una grilla de celdas en filas de `por_fila`.
714fn grilla<Msg: Clone + 'static>(celdas: Vec<View<Msg>>, por_fila: usize) -> (View<Msg>, f32) {
715    let mut filas: Vec<View<Msg>> = Vec::new();
716    let mut resto = celdas;
717    while !resto.is_empty() {
718        let corte = por_fila.min(resto.len());
719        let fila: Vec<View<Msg>> = resto.drain(..corte).collect();
720        filas.push(
721            View::<Msg>::new(Style {
722                flex_direction: FlexDirection::Row,
723                size: Size {
724                    width: length(CELDA_W * por_fila as f32),
725                    height: length(CELDA_H),
726                },
727                ..Default::default()
728            })
729            .children(fila),
730        );
731    }
732    let alto = filas.len() as f32 * CELDA_H;
733    (
734        View::<Msg>::new(Style {
735            flex_direction: FlexDirection::Column,
736            size: Size { width: length(CELDA_W * por_fila as f32), height: length(alto) },
737            ..Default::default()
738        })
739        .children(filas),
740        alto,
741    )
742}
743
744/// Rótulo de sección dentro del popup de hora.
745fn rotulo<Msg: Clone + 'static>(texto: &str, p: &PickerPalette) -> View<Msg> {
746    View::<Msg>::new(Style {
747        size: Size { width: percent(1.0_f32), height: length(18.0_f32) },
748        align_items: Some(AlignItems::CENTER),
749        ..Default::default()
750    })
751    .text_aligned(texto.to_string(), 10.5, p.fg_placeholder, Alignment::Start)
752}
753
754/// Monta el popup de hora. Elegir una hora conserva los minutos actuales y
755/// viceversa: los dos ejes son independientes, así que cambiar la hora no te
756/// borra el minuto que ya habías elegido.
757pub fn time_picker_popup<Msg: Clone + 'static>(spec: TimePopup<Msg>) -> View<Msg> {
758    let TimePopup {
759        anchor,
760        viewport,
761        valor,
762        formato,
763        paso_minutos,
764        on_pick,
765        on_dismiss,
766        palette,
767    } = spec;
768    let actual = valor.unwrap_or_default();
769
770    let celdas_h: Vec<View<Msg>> = (0..24u8)
771        .map(|h| {
772            let hora = Hora { h, m: actual.m };
773            celda(
774                match formato {
775                    FormatoHora::H24 => format!("{h:02}"),
776                    FormatoHora::H12 => {
777                        let (h12, pm) = (Hora { h, m: 0 }).en_12();
778                        format!("{h12}{}", if pm { "p" } else { "a" })
779                    }
780                },
781                format!("Hora {}", hora.formatear(formato)),
782                valor.map(|v| v.h) == Some(h),
783                (on_pick)(hora),
784                &palette,
785            )
786        })
787        .collect();
788    let (grilla_h, alto_h) = grilla(celdas_h, HORAS_POR_FILA);
789
790    let minutos = minutos_ofrecidos(paso_minutos);
791    let por_fila_m = HORAS_POR_FILA;
792    let celdas_m: Vec<View<Msg>> = minutos
793        .iter()
794        .map(|&m| {
795            let hora = Hora { h: actual.h, m };
796            celda(
797                format!(":{m:02}"),
798                format!("Minuto {m:02}"),
799                valor.map(|v| v.m) == Some(m),
800                (on_pick)(hora),
801                &palette,
802            )
803        })
804        .collect();
805    let (grilla_m, alto_m) = grilla(celdas_m, por_fila_m);
806
807    let ancho = CELDA_W * HORAS_POR_FILA as f32 + PANEL_PAD * 2.0;
808    let alto = 18.0 * 2.0 + alto_h + alto_m + PANEL_PAD * 2.0 + 6.0;
809    let (x, y) = posicion(anchor, viewport, ancho, alto);
810
811    let panel = View::<Msg>::new(Style {
812        flex_direction: FlexDirection::Column,
813        size: Size { width: length(ancho), height: length(alto) },
814        padding: Rect {
815            left: length(PANEL_PAD),
816            right: length(PANEL_PAD),
817            top: length(PANEL_PAD),
818            bottom: length(PANEL_PAD),
819        },
820        gap: Size { width: length(0.0_f32), height: length(3.0_f32) },
821        ..Default::default()
822    })
823    .fill(palette.bg_panel)
824    .radius(8.0)
825    .border(1.0, palette.borde)
826    .children(vec![
827        rotulo("Hora", &palette),
828        grilla_h,
829        rotulo("Minutos", &palette),
830        grilla_m,
831    ]);
832
833    envoltura(panel, x, y, on_dismiss, &palette)
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839
840    fn f(y: i32, m: u32, d: u32) -> NaiveDate {
841        NaiveDate::from_ymd_opt(y, m, d).unwrap()
842    }
843
844    #[test]
845    fn los_tres_formatos_de_fecha() {
846        let d = f(2026, 8, 4);
847        assert_eq!(formatear_fecha(d, FormatoFecha::Iso), "2026-08-04");
848        assert_eq!(formatear_fecha(d, FormatoFecha::DiaMesAnio), "04/08/2026");
849        assert_eq!(formatear_fecha(d, FormatoFecha::Largo), "4 de agosto de 2026");
850    }
851
852    #[test]
853    fn parsear_acepta_las_dos_formas_que_la_gente_escribe() {
854        // ISO y día-primero, con los tres separadores y sin ceros.
855        assert_eq!(parsear_fecha("2026-08-04"), Some(f(2026, 8, 4)));
856        assert_eq!(parsear_fecha("04/08/2026"), Some(f(2026, 8, 4)));
857        assert_eq!(parsear_fecha("4/8/2026"), Some(f(2026, 8, 4)));
858        assert_eq!(parsear_fecha(" 4.8.2026 "), Some(f(2026, 8, 4)));
859        // Ida y vuelta con los tres formatos de salida que son parseables.
860        for fmt in [FormatoFecha::Iso, FormatoFecha::DiaMesAnio] {
861            let d = f(2026, 12, 31);
862            assert_eq!(parsear_fecha(&formatear_fecha(d, fmt)), Some(d), "{fmt:?}");
863        }
864    }
865
866    #[test]
867    fn parsear_rechaza_lo_que_no_es_una_fecha() {
868        assert_eq!(parsear_fecha("31/02/2026"), None, "el 31 de febrero no existe");
869        assert_eq!(parsear_fecha("2026-13-01"), None, "no hay mes 13");
870        assert_eq!(parsear_fecha("mañana"), None);
871        assert_eq!(parsear_fecha("4/8"), None, "faltan campos");
872        assert_eq!(parsear_fecha(""), None);
873        // Un año de dos dígitos se RECHAZA en vez de completarse a mano: "26"
874        // podría ser 2026, 1926 o el año 26, y adivinar en una fecha es peor que
875        // devolver `None` y dejar el campo en rojo.
876        assert_eq!(parsear_fecha("4/8/26"), None);
877        assert_eq!(parsear_fecha("26-08-04"), None);
878    }
879
880    #[test]
881    fn las_doce_del_dia_y_de_la_noche_son_el_caso_que_todos_erran() {
882        // Un `% 12` pelado da 0 y escribe «0:00 AM».
883        assert_eq!(Hora { h: 0, m: 0 }.formatear(FormatoHora::H12), "12:00 AM");
884        assert_eq!(Hora { h: 12, m: 0 }.formatear(FormatoHora::H12), "12:00 PM");
885        assert_eq!(Hora { h: 13, m: 5 }.formatear(FormatoHora::H12), "1:05 PM");
886        assert_eq!(Hora { h: 13, m: 5 }.formatear(FormatoHora::H24), "13:05");
887        // Y la vuelta.
888        assert_eq!(Hora::desde_12(12, false, 0), Hora::nueva(0, 0));
889        assert_eq!(Hora::desde_12(12, true, 0), Hora::nueva(12, 0));
890        assert_eq!(Hora::desde_12(1, true, 5), Hora::nueva(13, 5));
891        assert_eq!(Hora::desde_12(0, false, 0), None, "no existe la hora 0 en 12 h");
892    }
893
894    #[test]
895    fn parsear_hora_toma_lo_que_se_tipea_de_verdad() {
896        assert_eq!(Hora::parsear("14:05"), Hora::nueva(14, 5));
897        assert_eq!(Hora::parsear("14.05"), Hora::nueva(14, 5));
898        assert_eq!(Hora::parsear("14h05"), Hora::nueva(14, 5));
899        assert_eq!(Hora::parsear("1405"), Hora::nueva(14, 5));
900        assert_eq!(Hora::parsear(" 2:05 pm "), Hora::nueva(14, 5));
901        assert_eq!(Hora::parsear("2 PM"), Hora::nueva(14, 0));
902        assert_eq!(Hora::parsear("9"), Hora::nueva(9, 0));
903        // Lo que no es una hora se rechaza en vez de inventarse un corte.
904        assert_eq!(Hora::parsear("25:00"), None);
905        assert_eq!(Hora::parsear("10:75"), None);
906        assert_eq!(Hora::parsear("140"), None);
907        assert_eq!(Hora::parsear("tarde"), None);
908        // Ida y vuelta en los dos formatos.
909        for h in [Hora { h: 0, m: 0 }, Hora { h: 12, m: 30 }, Hora { h: 23, m: 59 }] {
910            for fmt in [FormatoHora::H24, FormatoHora::H12] {
911                assert_eq!(Hora::parsear(&h.formatear(fmt)), Some(h), "{h:?} {fmt:?}");
912            }
913        }
914    }
915
916    #[test]
917    fn mover_con_flechas_envuelve_y_respeta_el_paso() {
918        let h = Hora { h: 23, m: 55 };
919        // La hora envuelve al día siguiente sin llevarse los minutos puestos.
920        assert_eq!(h.mover(CampoHora::Hora, 1, 5), Hora { h: 0, m: 55 });
921        // El minuto se mueve DE A PASO, y al envolver arrastra la hora.
922        assert_eq!(h.mover(CampoHora::Minuto, 1, 5), Hora { h: 0, m: 0 });
923        assert_eq!(Hora { h: 10, m: 10 }.mover(CampoHora::Minuto, 1, 5), Hora { h: 10, m: 15 });
924        assert_eq!(Hora { h: 0, m: 0 }.mover(CampoHora::Minuto, -1, 15), Hora { h: 23, m: 45 });
925        // Paso 0 no divide por cero ni congela la tecla.
926        assert_eq!(Hora { h: 1, m: 0 }.mover(CampoHora::Minuto, 1, 0), Hora { h: 1, m: 1 });
927    }
928
929    #[test]
930    fn el_paso_redondea_hacia_abajo_y_los_minutos_ofrecidos_lo_respetan() {
931        assert_eq!(Hora { h: 9, m: 47 }.al_paso(15), Hora { h: 9, m: 45 });
932        assert_eq!(Hora { h: 9, m: 47 }.al_paso(5), Hora { h: 9, m: 45 });
933        assert_eq!(Hora { h: 9, m: 47 }.al_paso(0), Hora { h: 9, m: 47 });
934        assert_eq!(minutos_ofrecidos(15), vec![0, 15, 30, 45]);
935        assert_eq!(minutos_ofrecidos(30), vec![0, 30]);
936        assert_eq!(minutos_ofrecidos(5).len(), 12);
937        // Paso 0 no cuelga: se trata como 1.
938        assert_eq!(minutos_ofrecidos(0).len(), 60);
939    }
940
941    #[test]
942    fn el_panel_se_queda_adentro_del_viewport() {
943        // Ancla pegada al borde derecho/inferior: el panel se corre para entrar.
944        let (x, y) = posicion((900.0, 700.0), (1000.0, 800.0), 300.0, 300.0);
945        assert!(x + 300.0 <= 1000.0 && y + 300.0 <= 800.0, "({x}, {y})");
946        // Ancla normal: se respeta.
947        assert_eq!(posicion((100.0, 100.0), (1000.0, 800.0), 300.0, 300.0), (100.0, 100.0));
948        // Viewport más chico que el panel: no se va a coordenadas negativas.
949        let (x, y) = posicion((0.0, 0.0), (100.0, 100.0), 300.0, 300.0);
950        assert!(x >= 0.0 && y >= 0.0);
951    }
952}