tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1//! `llimphi-widget-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}