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-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}