//! `llimphi-widget-seven-segment` — **la pantalla de siete palitos**. //! //! Los diez dígitos como los dibuja un reloj digital: siete palitos por cifra, //! los que la cifra no usa apagados pero **presentes** (el fantasma), y la //! transformación de un dígito al siguiente hecha palito por palito en vez de //! cambiando un glifo por otro. //! //! ## Por qué es un widget y no una fuente //! //! Una fuente de siete segmentos dibuja el resultado; acá hace falta el //! **camino**: qué palito se prende y cuál se apaga cuando el `3` pasa a ser //! `4`. Eso no se puede pedir con texto — un glifo no tiene partes que se //! puedan interpolar. Y de paso resuelve solo lo que la monoespaciada resolvía a //! medias: en siete palitos el `6` y el `8` no se distinguen por un remate de //! dos píxeles sino por **un palito entero** (le falta el de arriba a la //! derecha), que es una diferencia que se ve de reojo y a cualquier tamaño. //! //! ## Las esquinas limadas //! //! Los palitos son rectángulos redondeados y no hexágonos con punta. Es la //! diferencia entre un reloj de tablero de auto (puntas en diagonal, cada palito //! encajando con el vecino) y uno de cocina, y lo segundo es lo pedido: *«un //! poco limadas las esquinas de los palitos»*. El radio va en [`Pantalla::limado`] //! como fracción del grosor, así que `0.0` da el bloque duro y `0.5` la píldora. //! //! ## Sin estado //! //! [`Cifra`] lleva de dónde viene y cuánto va del camino (`t`), pero no lo //! calcula: se lo da quien pinta, derivado del instante. Un widget que guardara //! el dígito anterior en un campo mutable tendría que reconciliarse entre //! cuadros, y se desincronizaría en cuanto se saltara uno — que es lo que pasa //! en cuanto el compositor baja el ritmo. #![forbid(unsafe_code)] #![no_std] extern crate alloc; use alloc::vec::Vec; use llimphi_compositor::llimphi_layout::taffy::prelude::{length, Size, Style}; use llimphi_compositor::peniko::Color; use llimphi_compositor::View; use llimphi_theme::Theme; /// Los siete palitos, en el orden clásico `a..g`. /// /// ```text /// aaaa /// f b /// f b /// gggg /// e c /// e c /// dddd /// ``` pub const PALITOS: usize = 7; /// Qué palitos prende cada dígito, en el orden `[a, b, c, d, e, f, g]`. /// /// Un dígito fuera de `0..=9` no prende ninguno: es el hueco, y sirve para que /// una cifra que todavía no existe (el reloj arrancando) no dibuje basura. pub const fn palitos_de(d: u8) -> [bool; PALITOS] { match d { // a b c d e f g 0 => [true, true, true, true, true, true, false], 1 => [false, true, true, false, false, false, false], 2 => [true, true, false, true, true, false, true], 3 => [true, true, true, true, false, false, true], 4 => [false, true, true, false, false, true, true], 5 => [true, false, true, true, false, true, true], 6 => [true, false, true, true, true, true, true], 7 => [true, true, true, false, false, false, false], 8 => [true, true, true, true, true, true, true], 9 => [true, true, true, true, false, true, true], _ => [false; PALITOS], } } /// **Una cifra en camino.** `valor` es el dígito que se está mostrando; `desde` /// es de cuál viene (`None` = ya llegó, o nunca hubo otro) y `t` cuánto lleva /// andado, `0.0..=1.0`. /// /// `t` lo calcula quien pinta a partir del reloj. Acá no se guarda nada: ver la /// nota del encabezado. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Cifra { pub valor: u8, pub desde: Option, pub t: f32, } impl Cifra { /// Una cifra quieta. pub const fn quieta(valor: u8) -> Self { Self { valor, desde: None, t: 1.0 } } /// Una cifra que viene de `desde` y lleva `t` de camino. Si `desde` es el /// mismo valor, no hay camino: la transformación de un dígito a sí mismo es /// un parpadeo que nadie pidió. pub fn en_camino(valor: u8, desde: u8, t: f32) -> Self { if desde == valor { return Self::quieta(valor); } Self { valor, desde: Some(desde), t: t.clamp(0.0, 1.0) } } /// Cuánto está prendido cada palito ahora mismo, `0.0..=1.0`. /// /// El que ya estaba y sigue vale `1.0` sin interpolar: si se interpolara, un /// dígito que comparte seis palitos con el siguiente **parpadearía entero** /// en cada cambio, y la transformación se leería como un cambio de glifo — /// justo lo que se quiso evitar. pub fn encendido(&self) -> [f32; PALITOS] { let a = palitos_de(self.valor); let Some(d) = self.desde else { return núcleo(a); }; let b = palitos_de(d); let mut out = [0.0; PALITOS]; for i in 0..PALITOS { out[i] = match (b[i], a[i]) { (true, true) => 1.0, (false, false) => 0.0, (false, true) => suave(self.t), (true, false) => 1.0 - suave(self.t), }; } out } } const fn núcleo(a: [bool; PALITOS]) -> [f32; PALITOS] { let mut out = [0.0; PALITOS]; let mut i = 0; while i < PALITOS { out[i] = if a[i] { 1.0 } else { 0.0 }; i += 1; } out } /// `smoothstep`: arranca y termina quieto. Un palito que se prende linealmente /// se lee como un fundido de vídeo; con esto se lee como algo que se enciende. fn suave(t: f32) -> f32 { let t = t.clamp(0.0, 1.0); t * t * (3.0 - 2.0 * t) } /// Qué va en cada posición de la pantalla. #[derive(Debug, Clone, Copy, PartialEq)] pub enum Glifo { /// Una cifra de siete palitos. Cifra(Cifra), /// El separador `:`. `encendido` porque en un reloj digital titila, y quien /// pinta ya tiene el instante para decidirlo. DosPuntos { encendido: bool }, /// Un hueco del ancho de una cifra. Para alinear columnas. Hueco, } /// La paleta: el palito prendido y el **fantasma**, que es el apagado. /// /// El fantasma no es decoración: es lo que hace que la pantalla se lea como una /// pantalla y no como números raros. Sin él, el `1` es dos palitos flotando en /// el aire y no se entiende dónde termina la cifra. `None` lo apaga, para el /// look de LED sobre negro. #[derive(Debug, Clone, Copy)] pub struct SieteSegPalette { pub fantasma: Option, } impl SieteSegPalette { /// El fantasma del tema: el borde, que es el gris más callado que hay. pub fn from_theme(t: &Theme) -> Self { Self { fantasma: Some(t.border) } } } /// **Una pantalla de siete segmentos.** /// /// El color va **por glifo** y no en la paleta: es lo que deja que las horas y /// los minutos corran cada uno por su escala sin que el widget sepa nada de /// relojes. Ver [`Pantalla::glifos`]. #[derive(Debug, Clone)] pub struct Pantalla { /// Qué se dibuja y de qué color, de izquierda a derecha. pub glifos: Vec<(Glifo, Color)>, /// Alto de una cifra, en píxeles. Todo lo demás sale de acá. pub alto: f32, /// Grosor del palito, como fracción del alto. `0.15` es el clásico. pub grosor: f32, /// Radio de las esquinas, como fracción del grosor. `0.0` bloque duro, /// `0.5` píldora. *«Un poco limadas»* es `0.3`. pub limado: f32, /// Hueco entre glifos, como fracción del ancho de una cifra. pub separacion: f32, pub palette: SieteSegPalette, } impl Pantalla { /// Una pantalla con las proporciones de fábrica. pub fn nueva(glifos: Vec<(Glifo, Color)>, alto: f32, palette: SieteSegPalette) -> Self { Self { glifos, alto, grosor: 0.15, limado: 0.3, separacion: SEPARACION_DEFECTO, palette } } } /// El hueco de fábrica entre glifos, como fracción del ancho de una cifra. /// Menos que esto y las cifras se tocan — mirado en SVG con los diez dígitos /// seguidos, que es donde se ve y no en un número. pub const SEPARACION_DEFECTO: f32 = 0.22; /// El ancho de una cifra para un alto dado. /// /// Las proporciones de un dígito de siete palitos van entre ~0,55 y ~0,58 de su /// alto; más angosto que eso y los palitos horizontales dejan de leerse como /// palitos. Estuvo en 0,58 hasta el 2026-09-07, cuando el reloj de la barra se /// desbordó del hueco y el último dígito quedaba **cortado por el canto** — /// visto en el volcado de la barra. A 0,55 la pantalla de cuatro cifras ahorra /// casi un dígito de ancho a 22 px y sigue leyéndose. pub fn ancho_de_cifra(alto: f32) -> f32 { alto * 0.55 } /// El ancho del `:`, que es mucho menor que el de una cifra. pub fn ancho_de_dos_puntos(alto: f32) -> f32 { alto * 0.22 } /// **Cuánto mide la pantalla entera.** Público porque quien la mete en una /// barra necesita reservarle el espacio antes de que se pinte: un nodo con /// `paint_with` y sin tamaño mide cero y el layout lo aplasta. pub fn ancho_total(p: &Pantalla) -> f32 { let cifra = ancho_de_cifra(p.alto); let hueco = cifra * p.separacion; let mut w = 0.0_f32; for (i, (g, _)) in p.glifos.iter().enumerate() { if i > 0 { w += hueco; } w += match g { Glifo::DosPuntos { .. } => ancho_de_dos_puntos(p.alto), _ => cifra, }; } w } /// La vista. `etiqueta` es lo que el lector de pantalla dice: los palitos no son /// texto, así que sin esto la hora **no existe** para quien no la ve. pub fn siete_seg_view(p: Pantalla, etiqueta: alloc::string::String) -> View { let w = ancho_total(&p); let h = p.alto; View::new(Style { size: Size { width: length(w), height: length(h) }, flex_shrink: 0.0, ..Default::default() }) .role(llimphi_compositor::Role::Label) .aria_label(etiqueta) .paint_with(move |scene, _ts, rect| pintar(scene, rect, &p)) } fn pintar( scene: &mut llimphi_compositor::Lienzo, rect: llimphi_compositor::PaintRect, p: &Pantalla, ) { if rect.w <= 0.0 || rect.h <= 0.0 || p.alto <= 0.0 { return; } let cifra = ancho_de_cifra(p.alto); let hueco = cifra * p.separacion; let mut x = rect.x; for (i, (g, color)) in p.glifos.iter().enumerate() { if i > 0 { x += hueco; } match g { Glifo::Cifra(c) => { pintar_cifra(scene, x, rect.y, cifra, p, &c.encendido(), *color); x += cifra; } Glifo::DosPuntos { encendido } => { let w = ancho_de_dos_puntos(p.alto); pintar_dos_puntos(scene, x, rect.y, w, p, *encendido, *color); x += w; } Glifo::Hueco => x += cifra, } } } /// Los siete rectángulos de una cifra, en la caja `(x, y, w, p.alto)`. /// /// Cada palito se dibuja **dos veces cuando está a medias**: el fantasma /// completo debajo y el prendido encima con la alfa del camino. Interpolar el /// color entre fantasma y encendido daría un gris sucio en el medio; superponer /// da lo que hace un LED de verdad, que es aparecer sobre lo que ya estaba. fn pintar_cifra( scene: &mut llimphi_compositor::Lienzo, x: f32, y: f32, w: f32, p: &Pantalla, encendido: &[f32; PALITOS], color: Color, ) { let h = p.alto; let g = (h * p.grosor).max(1.0); let r = (g * p.limado) as f64; // El sangrado con el que un palito no toca al vecino. Sin él, la `g` del // medio se pega a la `f` y la cifra se lee como una mancha. let s = g * 0.55; let medio = (h - g) * 0.5; // (x0, y0, x1, y1) de cada palito, en el orden a..g. let cajas = [ (x + s, y, x + w - s, y + g), // a — arriba (x + w - g, y + s, x + w, y + medio + g - s), // b — arriba derecha (x + w - g, y + medio + s, x + w, y + h - s), // c — abajo derecha (x + s, y + h - g, x + w - s, y + h), // d — abajo (x, y + medio + s, x + g, y + h - s), // e — abajo izquierda (x, y + s, x + g, y + medio + g - s), // f — arriba izquierda (x + s, y + medio, x + w - s, y + medio + g), // g — medio ]; for (i, (x0, y0, x1, y1)) in cajas.iter().enumerate() { let alfa = encendido[i]; if let Some(fantasma) = p.palette.fantasma { palito(scene, *x0, *y0, *x1, *y1, r, fantasma); } if alfa > 0.004 { palito(scene, *x0, *y0, *x1, *y1, r, color.with_alpha(alfa)); } } } fn palito( scene: &mut llimphi_compositor::Lienzo, x0: f32, y0: f32, x1: f32, y1: f32, r: f64, color: Color, ) { use llimphi_compositor::peniko::kurbo::{Affine, RoundedRect}; use llimphi_compositor::peniko::Fill; if x1 <= x0 || y1 <= y0 { return; } // El radio no puede pasarse de la media del lado corto, o kurbo dibuja una // figura que no es la que se pidió. let corto = ((x1 - x0).min(y1 - y0) * 0.5) as f64; let r = r.min(corto).max(0.0); scene.fill( Fill::NonZero, Affine::IDENTITY, color, None, &RoundedRect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64, r), ); } /// Los dos puntos: dos cuadraditos del grosor del palito, a la altura de los /// tercios. Mismo trato de fantasma que los palitos, así que al titilar la /// pantalla no cambia de forma. fn pintar_dos_puntos( scene: &mut llimphi_compositor::Lienzo, x: f32, y: f32, w: f32, p: &Pantalla, encendido: bool, color: Color, ) { let h = p.alto; let g = (h * p.grosor).max(1.0); let r = (g * p.limado) as f64; let cx = x + w * 0.5; for f in [0.30_f32, 0.70] { let cy = y + h * f; let (x0, y0, x1, y1) = (cx - g * 0.5, cy - g * 0.5, cx + g * 0.5, cy + g * 0.5); if let Some(fantasma) = p.palette.fantasma { palito(scene, x0, y0, x1, y1, r, fantasma); } if encendido { palito(scene, x0, y0, x1, y1, r, color); } } } #[cfg(test)] mod tests { use super::*; /// Los diez dígitos tienen que ser **diez patrones distintos**, o la /// pantalla dice una cosa por otra. Es la afirmación que la tabla de arriba /// necesita: está escrita a mano y una fila mal copiada no se ve leyéndola. #[test] fn los_diez_digitos_son_diez_dibujos_distintos() { for a in 0u8..=9 { for b in 0u8..=9 { if a != b { assert_ne!(palitos_de(a), palitos_de(b), "{a} y {b} se dibujan igual"); } } } } /// **Lo que la monoespaciada resolvía a medias.** Pedido de origen (AS29): /// *«yo que soy ciego confundo el 8 con el 6»*. En siete palitos el 8 y el 6 /// difieren en uno entero, no en un remate. #[test] fn el_ocho_y_el_seis_difieren_en_un_palito_entero() { let (ocho, seis) = (palitos_de(8), palitos_de(6)); let distintos: usize = (0..PALITOS).filter(|i| ocho[*i] != seis[*i]).count(); assert_eq!(distintos, 1, "sólo el de arriba a la derecha"); assert!(ocho[1] && !seis[1], "y es la `b`"); // Y el 8 los tiene todos: es la referencia contra la que se lee el resto. assert!(palitos_de(8).iter().all(|x| *x)); } /// Un dígito fuera de rango no dibuja basura: dibuja nada. #[test] fn lo_que_no_es_un_digito_no_prende_nada() { assert!(palitos_de(10).iter().all(|x| !x)); assert!(palitos_de(255).iter().all(|x| !x)); } /// **La transformación es palito por palito.** Del 3 al 4: la `a` y la `d` /// se apagan, la `f` se prende, y las que comparten NO se mueven — si se /// movieran, la cifra entera parpadearía y se leería como un cambio de /// glifo, que es lo que este widget existe para no hacer. #[test] fn del_tres_al_cuatro_solo_se_mueve_lo_que_cambia() { let c = Cifra::en_camino(4, 3, 0.5); let e = c.encendido(); // b y c están en los dos: quietas y enteras. assert_eq!(e[1], 1.0); assert_eq!(e[2], 1.0); // g está en los dos. assert_eq!(e[6], 1.0); // f entra, a y d salen — y a mitad de camino van por la mitad. assert!((e[5] - 0.5).abs() < 0.01, "f entrando: {}", e[5]); assert!((e[0] - 0.5).abs() < 0.01, "a saliendo: {}", e[0]); assert!((e[3] - 0.5).abs() < 0.01, "d saliendo: {}", e[3]); } /// Los extremos del camino son exactamente los dos dígitos. Un `t` que no /// aterrice deja la pantalla mostrando algo que no es ningún número. #[test] fn el_camino_empieza_y_termina_en_un_digito_de_verdad() { let de_nueve_a_cero = |t| Cifra::en_camino(0, 9, t).encendido(); assert_eq!(de_nueve_a_cero(0.0), núcleo(palitos_de(9))); assert_eq!(de_nueve_a_cero(1.0), núcleo(palitos_de(0))); // Y fuera de rango se acota en vez de extrapolar. assert_eq!(de_nueve_a_cero(-5.0), núcleo(palitos_de(9))); assert_eq!(de_nueve_a_cero(9.0), núcleo(palitos_de(0))); } /// De un dígito a sí mismo no hay camino: sería un parpadeo que nadie pidió. #[test] fn de_un_digito_a_si_mismo_no_pasa_nada() { assert_eq!(Cifra::en_camino(7, 7, 0.5), Cifra::quieta(7)); assert_eq!(Cifra::quieta(7).encendido(), núcleo(palitos_de(7))); } /// El ancho se puede saber **antes** de pintar: un nodo `paint_with` sin /// tamaño mide cero y el layout lo aplasta. #[test] fn el_ancho_se_sabe_antes_de_pintar() { let c = Color::WHITE; let p = Pantalla::nueva( alloc::vec![ (Glifo::Cifra(Cifra::quieta(1)), c), (Glifo::Cifra(Cifra::quieta(2)), c), (Glifo::DosPuntos { encendido: true }, c), (Glifo::Cifra(Cifra::quieta(3)), c), (Glifo::Cifra(Cifra::quieta(4)), c), ], 40.0, SieteSegPalette { fantasma: None }, ); let cifra = ancho_de_cifra(40.0); let dp = ancho_de_dos_puntos(40.0); let hueco = cifra * p.separacion; assert!((ancho_total(&p) - (4.0 * cifra + dp + 4.0 * hueco)).abs() < 0.01); // Y el `:` es MUCHO más angosto que una cifra: si midiera igual, «12:34» // saldría con un agujero en el medio. assert!(dp < cifra * 0.5); } }