tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definitions

⎇ main widgets/seven-segment/src/lib.rs Rust · 494 lines · 18.0 KB History Who touched what raw

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

1//! `llimphi-widget-seven-segment` — **la pantalla de siete palitos**.
2//!
3//! Los diez dígitos como los dibuja un reloj digital: siete palitos por cifra,
4//! los que la cifra no usa apagados pero **presentes** (el fantasma), y la
5//! transformación de un dígito al siguiente hecha palito por palito en vez de
6//! cambiando un glifo por otro.
7//!
8//! ## Por qué es un widget y no una fuente
9//!
10//! Una fuente de siete segmentos dibuja el resultado; acá hace falta el
11//! **camino**: qué palito se prende y cuál se apaga cuando el `3` pasa a ser
12//! `4`. Eso no se puede pedir con texto — un glifo no tiene partes que se
13//! puedan interpolar. Y de paso resuelve solo lo que la monoespaciada resolvía a
14//! medias: en siete palitos el `6` y el `8` no se distinguen por un remate de
15//! dos píxeles sino por **un palito entero** (le falta el de arriba a la
16//! derecha), que es una diferencia que se ve de reojo y a cualquier tamaño.
17//!
18//! ## Las esquinas limadas
19//!
20//! Los palitos son rectángulos redondeados y no hexágonos con punta. Es la
21//! diferencia entre un reloj de tablero de auto (puntas en diagonal, cada palito
22//! encajando con el vecino) y uno de cocina, y lo segundo es lo pedido: *«un
23//! poco limadas las esquinas de los palitos»*. El radio va en [`Pantalla::limado`]
24//! como fracción del grosor, así que `0.0` da el bloque duro y `0.5` la píldora.
25//!
26//! ## Sin estado
27//!
28//! [`Cifra`] lleva de dónde viene y cuánto va del camino (`t`), pero no lo
29//! calcula: se lo da quien pinta, derivado del instante. Un widget que guardara
30//! el dígito anterior en un campo mutable tendría que reconciliarse entre
31//! cuadros, y se desincronizaría en cuanto se saltara uno — que es lo que pasa
32//! en cuanto el compositor baja el ritmo.
33
34#![forbid(unsafe_code)]
35#![no_std]
36
37extern crate alloc;
38
39use alloc::vec::Vec;
40use llimphi_compositor::llimphi_layout::taffy::prelude::{length, Size, Style};
41use llimphi_compositor::peniko::Color;
42use llimphi_compositor::View;
43use llimphi_theme::Theme;
44
45/// Los siete palitos, en el orden clásico `a..g`.
46///
47/// ```text
48///    aaaa
49///   f    b
50///   f    b
51///    gggg
52///   e    c
53///   e    c
54///    dddd
55/// ```
56pub const PALITOS: usize = 7;
57
58/// Qué palitos prende cada dígito, en el orden `[a, b, c, d, e, f, g]`.
59///
60/// Un dígito fuera de `0..=9` no prende ninguno: es el hueco, y sirve para que
61/// una cifra que todavía no existe (el reloj arrancando) no dibuje basura.
62pub const fn palitos_de(d: u8) -> [bool; PALITOS] {
63    match d {
64        //     a      b      c      d      e      f      g
65        0 => [true, true, true, true, true, true, false],
66        1 => [false, true, true, false, false, false, false],
67        2 => [true, true, false, true, true, false, true],
68        3 => [true, true, true, true, false, false, true],
69        4 => [false, true, true, false, false, true, true],
70        5 => [true, false, true, true, false, true, true],
71        6 => [true, false, true, true, true, true, true],
72        7 => [true, true, true, false, false, false, false],
73        8 => [true, true, true, true, true, true, true],
74        9 => [true, true, true, true, false, true, true],
75        _ => [false; PALITOS],
76    }
77}
78
79/// **Una cifra en camino.** `valor` es el dígito que se está mostrando; `desde`
80/// es de cuál viene (`None` = ya llegó, o nunca hubo otro) y `t` cuánto lleva
81/// andado, `0.0..=1.0`.
82///
83/// `t` lo calcula quien pinta a partir del reloj. Acá no se guarda nada: ver la
84/// nota del encabezado.
85#[derive(Debug, Clone, Copy, PartialEq)]
86pub struct Cifra {
87    pub valor: u8,
88    pub desde: Option<u8>,
89    pub t: f32,
90}
91
92impl Cifra {
93    /// Una cifra quieta.
94    pub const fn quieta(valor: u8) -> Self {
95        Self { valor, desde: None, t: 1.0 }
96    }
97
98    /// Una cifra que viene de `desde` y lleva `t` de camino. Si `desde` es el
99    /// mismo valor, no hay camino: la transformación de un dígito a sí mismo es
100    /// un parpadeo que nadie pidió.
101    pub fn en_camino(valor: u8, desde: u8, t: f32) -> Self {
102        if desde == valor {
103            return Self::quieta(valor);
104        }
105        Self { valor, desde: Some(desde), t: t.clamp(0.0, 1.0) }
106    }
107
108    /// Cuánto está prendido cada palito ahora mismo, `0.0..=1.0`.
109    ///
110    /// El que ya estaba y sigue vale `1.0` sin interpolar: si se interpolara, un
111    /// dígito que comparte seis palitos con el siguiente **parpadearía entero**
112    /// en cada cambio, y la transformación se leería como un cambio de glifo —
113    /// justo lo que se quiso evitar.
114    pub fn encendido(&self) -> [f32; PALITOS] {
115        let a = palitos_de(self.valor);
116        let Some(d) = self.desde else {
117            return núcleo(a);
118        };
119        let b = palitos_de(d);
120        let mut out = [0.0; PALITOS];
121        for i in 0..PALITOS {
122            out[i] = match (b[i], a[i]) {
123                (true, true) => 1.0,
124                (false, false) => 0.0,
125                (false, true) => suave(self.t),
126                (true, false) => 1.0 - suave(self.t),
127            };
128        }
129        out
130    }
131}
132
133const fn núcleo(a: [bool; PALITOS]) -> [f32; PALITOS] {
134    let mut out = [0.0; PALITOS];
135    let mut i = 0;
136    while i < PALITOS {
137        out[i] = if a[i] { 1.0 } else { 0.0 };
138        i += 1;
139    }
140    out
141}
142
143/// `smoothstep`: arranca y termina quieto. Un palito que se prende linealmente
144/// se lee como un fundido de vídeo; con esto se lee como algo que se enciende.
145fn suave(t: f32) -> f32 {
146    let t = t.clamp(0.0, 1.0);
147    t * t * (3.0 - 2.0 * t)
148}
149
150/// Qué va en cada posición de la pantalla.
151#[derive(Debug, Clone, Copy, PartialEq)]
152pub enum Glifo {
153    /// Una cifra de siete palitos.
154    Cifra(Cifra),
155    /// El separador `:`. `encendido` porque en un reloj digital titila, y quien
156    /// pinta ya tiene el instante para decidirlo.
157    DosPuntos { encendido: bool },
158    /// Un hueco del ancho de una cifra. Para alinear columnas.
159    Hueco,
160}
161
162/// La paleta: el palito prendido y el **fantasma**, que es el apagado.
163///
164/// El fantasma no es decoración: es lo que hace que la pantalla se lea como una
165/// pantalla y no como números raros. Sin él, el `1` es dos palitos flotando en
166/// el aire y no se entiende dónde termina la cifra. `None` lo apaga, para el
167/// look de LED sobre negro.
168#[derive(Debug, Clone, Copy)]
169pub struct SieteSegPalette {
170    pub fantasma: Option<Color>,
171}
172
173impl SieteSegPalette {
174    /// El fantasma del tema: el borde, que es el gris más callado que hay.
175    pub fn from_theme(t: &Theme) -> Self {
176        Self { fantasma: Some(t.border) }
177    }
178}
179
180/// **Una pantalla de siete segmentos.**
181///
182/// El color va **por glifo** y no en la paleta: es lo que deja que las horas y
183/// los minutos corran cada uno por su escala sin que el widget sepa nada de
184/// relojes. Ver [`Pantalla::glifos`].
185#[derive(Debug, Clone)]
186pub struct Pantalla {
187    /// Qué se dibuja y de qué color, de izquierda a derecha.
188    pub glifos: Vec<(Glifo, Color)>,
189    /// Alto de una cifra, en píxeles. Todo lo demás sale de acá.
190    pub alto: f32,
191    /// Grosor del palito, como fracción del alto. `0.15` es el clásico.
192    pub grosor: f32,
193    /// Radio de las esquinas, como fracción del grosor. `0.0` bloque duro,
194    /// `0.5` píldora. *«Un poco limadas»* es `0.3`.
195    pub limado: f32,
196    /// Hueco entre glifos, como fracción del ancho de una cifra.
197    pub separacion: f32,
198    pub palette: SieteSegPalette,
199}
200
201impl Pantalla {
202    /// Una pantalla con las proporciones de fábrica.
203    pub fn nueva(glifos: Vec<(Glifo, Color)>, alto: f32, palette: SieteSegPalette) -> Self {
204        Self { glifos, alto, grosor: 0.15, limado: 0.3, separacion: SEPARACION_DEFECTO, palette }
205    }
206}
207
208/// El hueco de fábrica entre glifos, como fracción del ancho de una cifra.
209/// Menos que esto y las cifras se tocan — mirado en SVG con los diez dígitos
210/// seguidos, que es donde se ve y no en un número.
211pub const SEPARACION_DEFECTO: f32 = 0.22;
212
213/// El ancho de una cifra para un alto dado.
214///
215/// Las proporciones de un dígito de siete palitos van entre ~0,55 y ~0,58 de su
216/// alto; más angosto que eso y los palitos horizontales dejan de leerse como
217/// palitos. Estuvo en 0,58 hasta el 2026-09-07, cuando el reloj de la barra se
218/// desbordó del hueco y el último dígito quedaba **cortado por el canto** —
219/// visto en el volcado de la barra. A 0,55 la pantalla de cuatro cifras ahorra
220/// casi un dígito de ancho a 22 px y sigue leyéndose.
221pub fn ancho_de_cifra(alto: f32) -> f32 {
222    alto * 0.55
223}
224
225/// El ancho del `:`, que es mucho menor que el de una cifra.
226pub fn ancho_de_dos_puntos(alto: f32) -> f32 {
227    alto * 0.22
228}
229
230/// **Cuánto mide la pantalla entera.** Público porque quien la mete en una
231/// barra necesita reservarle el espacio antes de que se pinte: un nodo con
232/// `paint_with` y sin tamaño mide cero y el layout lo aplasta.
233pub fn ancho_total(p: &Pantalla) -> f32 {
234    let cifra = ancho_de_cifra(p.alto);
235    let hueco = cifra * p.separacion;
236    let mut w = 0.0_f32;
237    for (i, (g, _)) in p.glifos.iter().enumerate() {
238        if i > 0 {
239            w += hueco;
240        }
241        w += match g {
242            Glifo::DosPuntos { .. } => ancho_de_dos_puntos(p.alto),
243            _ => cifra,
244        };
245    }
246    w
247}
248
249/// La vista. `etiqueta` es lo que el lector de pantalla dice: los palitos no son
250/// texto, así que sin esto la hora **no existe** para quien no la ve.
251pub fn siete_seg_view<Msg: Clone + 'static>(p: Pantalla, etiqueta: alloc::string::String) -> View<Msg> {
252    let w = ancho_total(&p);
253    let h = p.alto;
254    View::new(Style {
255        size: Size { width: length(w), height: length(h) },
256        flex_shrink: 0.0,
257        ..Default::default()
258    })
259    .role(llimphi_compositor::Role::Label)
260    .aria_label(etiqueta)
261    .paint_with(move |scene, _ts, rect| pintar(scene, rect, &p))
262}
263
264fn pintar(
265    scene: &mut llimphi_compositor::Lienzo,
266    rect: llimphi_compositor::PaintRect,
267    p: &Pantalla,
268) {
269    if rect.w <= 0.0 || rect.h <= 0.0 || p.alto <= 0.0 {
270        return;
271    }
272    let cifra = ancho_de_cifra(p.alto);
273    let hueco = cifra * p.separacion;
274    let mut x = rect.x;
275    for (i, (g, color)) in p.glifos.iter().enumerate() {
276        if i > 0 {
277            x += hueco;
278        }
279        match g {
280            Glifo::Cifra(c) => {
281                pintar_cifra(scene, x, rect.y, cifra, p, &c.encendido(), *color);
282                x += cifra;
283            }
284            Glifo::DosPuntos { encendido } => {
285                let w = ancho_de_dos_puntos(p.alto);
286                pintar_dos_puntos(scene, x, rect.y, w, p, *encendido, *color);
287                x += w;
288            }
289            Glifo::Hueco => x += cifra,
290        }
291    }
292}
293
294/// Los siete rectángulos de una cifra, en la caja `(x, y, w, p.alto)`.
295///
296/// Cada palito se dibuja **dos veces cuando está a medias**: el fantasma
297/// completo debajo y el prendido encima con la alfa del camino. Interpolar el
298/// color entre fantasma y encendido daría un gris sucio en el medio; superponer
299/// da lo que hace un LED de verdad, que es aparecer sobre lo que ya estaba.
300fn pintar_cifra(
301    scene: &mut llimphi_compositor::Lienzo,
302    x: f32,
303    y: f32,
304    w: f32,
305    p: &Pantalla,
306    encendido: &[f32; PALITOS],
307    color: Color,
308) {
309    let h = p.alto;
310    let g = (h * p.grosor).max(1.0);
311    let r = (g * p.limado) as f64;
312    // El sangrado con el que un palito no toca al vecino. Sin él, la `g` del
313    // medio se pega a la `f` y la cifra se lee como una mancha.
314    let s = g * 0.55;
315    let medio = (h - g) * 0.5;
316
317    // (x0, y0, x1, y1) de cada palito, en el orden a..g.
318    let cajas = [
319        (x + s, y, x + w - s, y + g),                       // a — arriba
320        (x + w - g, y + s, x + w, y + medio + g - s),       // b — arriba derecha
321        (x + w - g, y + medio + s, x + w, y + h - s),       // c — abajo derecha
322        (x + s, y + h - g, x + w - s, y + h),               // d — abajo
323        (x, y + medio + s, x + g, y + h - s),               // e — abajo izquierda
324        (x, y + s, x + g, y + medio + g - s),               // f — arriba izquierda
325        (x + s, y + medio, x + w - s, y + medio + g),       // g — medio
326    ];
327
328    for (i, (x0, y0, x1, y1)) in cajas.iter().enumerate() {
329        let alfa = encendido[i];
330        if let Some(fantasma) = p.palette.fantasma {
331            palito(scene, *x0, *y0, *x1, *y1, r, fantasma);
332        }
333        if alfa > 0.004 {
334            palito(scene, *x0, *y0, *x1, *y1, r, color.with_alpha(alfa));
335        }
336    }
337}
338
339fn palito(
340    scene: &mut llimphi_compositor::Lienzo,
341    x0: f32,
342    y0: f32,
343    x1: f32,
344    y1: f32,
345    r: f64,
346    color: Color,
347) {
348    use llimphi_compositor::peniko::kurbo::{Affine, RoundedRect};
349    use llimphi_compositor::peniko::Fill;
350    if x1 <= x0 || y1 <= y0 {
351        return;
352    }
353    // El radio no puede pasarse de la media del lado corto, o kurbo dibuja una
354    // figura que no es la que se pidió.
355    let corto = ((x1 - x0).min(y1 - y0) * 0.5) as f64;
356    let r = r.min(corto).max(0.0);
357    scene.fill(
358        Fill::NonZero,
359        Affine::IDENTITY,
360        color,
361        None,
362        &RoundedRect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64, r),
363    );
364}
365
366/// Los dos puntos: dos cuadraditos del grosor del palito, a la altura de los
367/// tercios. Mismo trato de fantasma que los palitos, así que al titilar la
368/// pantalla no cambia de forma.
369fn pintar_dos_puntos(
370    scene: &mut llimphi_compositor::Lienzo,
371    x: f32,
372    y: f32,
373    w: f32,
374    p: &Pantalla,
375    encendido: bool,
376    color: Color,
377) {
378    let h = p.alto;
379    let g = (h * p.grosor).max(1.0);
380    let r = (g * p.limado) as f64;
381    let cx = x + w * 0.5;
382    for f in [0.30_f32, 0.70] {
383        let cy = y + h * f;
384        let (x0, y0, x1, y1) = (cx - g * 0.5, cy - g * 0.5, cx + g * 0.5, cy + g * 0.5);
385        if let Some(fantasma) = p.palette.fantasma {
386            palito(scene, x0, y0, x1, y1, r, fantasma);
387        }
388        if encendido {
389            palito(scene, x0, y0, x1, y1, r, color);
390        }
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    /// Los diez dígitos tienen que ser **diez patrones distintos**, o la
399    /// pantalla dice una cosa por otra. Es la afirmación que la tabla de arriba
400    /// necesita: está escrita a mano y una fila mal copiada no se ve leyéndola.
401    #[test]
402    fn los_diez_digitos_son_diez_dibujos_distintos() {
403        for a in 0u8..=9 {
404            for b in 0u8..=9 {
405                if a != b {
406                    assert_ne!(palitos_de(a), palitos_de(b), "{a} y {b} se dibujan igual");
407                }
408            }
409        }
410    }
411
412    /// **Lo que la monoespaciada resolvía a medias.** Pedido de origen (AS29):
413    /// *«yo que soy ciego confundo el 8 con el 6»*. En siete palitos el 8 y el 6
414    /// difieren en uno entero, no en un remate.
415    #[test]
416    fn el_ocho_y_el_seis_difieren_en_un_palito_entero() {
417        let (ocho, seis) = (palitos_de(8), palitos_de(6));
418        let distintos: usize = (0..PALITOS).filter(|i| ocho[*i] != seis[*i]).count();
419        assert_eq!(distintos, 1, "sólo el de arriba a la derecha");
420        assert!(ocho[1] && !seis[1], "y es la `b`");
421        // Y el 8 los tiene todos: es la referencia contra la que se lee el resto.
422        assert!(palitos_de(8).iter().all(|x| *x));
423    }
424
425    /// Un dígito fuera de rango no dibuja basura: dibuja nada.
426    #[test]
427    fn lo_que_no_es_un_digito_no_prende_nada() {
428        assert!(palitos_de(10).iter().all(|x| !x));
429        assert!(palitos_de(255).iter().all(|x| !x));
430    }
431
432    /// **La transformación es palito por palito.** Del 3 al 4: la `a` y la `d`
433    /// se apagan, la `f` se prende, y las que comparten NO se mueven — si se
434    /// movieran, la cifra entera parpadearía y se leería como un cambio de
435    /// glifo, que es lo que este widget existe para no hacer.
436    #[test]
437    fn del_tres_al_cuatro_solo_se_mueve_lo_que_cambia() {
438        let c = Cifra::en_camino(4, 3, 0.5);
439        let e = c.encendido();
440        // b y c están en los dos: quietas y enteras.
441        assert_eq!(e[1], 1.0);
442        assert_eq!(e[2], 1.0);
443        // g está en los dos.
444        assert_eq!(e[6], 1.0);
445        // f entra, a y d salen — y a mitad de camino van por la mitad.
446        assert!((e[5] - 0.5).abs() < 0.01, "f entrando: {}", e[5]);
447        assert!((e[0] - 0.5).abs() < 0.01, "a saliendo: {}", e[0]);
448        assert!((e[3] - 0.5).abs() < 0.01, "d saliendo: {}", e[3]);
449    }
450
451    /// Los extremos del camino son exactamente los dos dígitos. Un `t` que no
452    /// aterrice deja la pantalla mostrando algo que no es ningún número.
453    #[test]
454    fn el_camino_empieza_y_termina_en_un_digito_de_verdad() {
455        let de_nueve_a_cero = |t| Cifra::en_camino(0, 9, t).encendido();
456        assert_eq!(de_nueve_a_cero(0.0), núcleo(palitos_de(9)));
457        assert_eq!(de_nueve_a_cero(1.0), núcleo(palitos_de(0)));
458        // Y fuera de rango se acota en vez de extrapolar.
459        assert_eq!(de_nueve_a_cero(-5.0), núcleo(palitos_de(9)));
460        assert_eq!(de_nueve_a_cero(9.0), núcleo(palitos_de(0)));
461    }
462
463    /// De un dígito a sí mismo no hay camino: sería un parpadeo que nadie pidió.
464    #[test]
465    fn de_un_digito_a_si_mismo_no_pasa_nada() {
466        assert_eq!(Cifra::en_camino(7, 7, 0.5), Cifra::quieta(7));
467        assert_eq!(Cifra::quieta(7).encendido(), núcleo(palitos_de(7)));
468    }
469
470    /// El ancho se puede saber **antes** de pintar: un nodo `paint_with` sin
471    /// tamaño mide cero y el layout lo aplasta.
472    #[test]
473    fn el_ancho_se_sabe_antes_de_pintar() {
474        let c = Color::WHITE;
475        let p = Pantalla::nueva(
476            alloc::vec![
477                (Glifo::Cifra(Cifra::quieta(1)), c),
478                (Glifo::Cifra(Cifra::quieta(2)), c),
479                (Glifo::DosPuntos { encendido: true }, c),
480                (Glifo::Cifra(Cifra::quieta(3)), c),
481                (Glifo::Cifra(Cifra::quieta(4)), c),
482            ],
483            40.0,
484            SieteSegPalette { fantasma: None },
485        );
486        let cifra = ancho_de_cifra(40.0);
487        let dp = ancho_de_dos_puntos(40.0);
488        let hueco = cifra * p.separacion;
489        assert!((ancho_total(&p) - (4.0 * cifra + dp + 4.0 * hueco)).abs() < 0.01);
490        // Y el `:` es MUCHO más angosto que una cifra: si midiera igual, «12:34»
491        // saldría con un agujero en el medio.
492        assert!(dp < cifra * 0.5);
493    }
494}