tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
1//! `llimphi-widget-spinner` — spinner circular animado por reloj absoluto.
2//!
3//! El paint usa `Instant::now()` para calcular el ángulo de rotación,
4//! así no hace falta que la app guarde un tween ni dispatchee ticks:
5//! cuando llimphi-ui rasterize un frame (porque algo cambió en el
6//! modelo o porque la app pidió un repaint), el spinner se ve girando.
7//!
8//! **Nota**: el spinner sólo se anima si HAY frames. Una app idle no
9//! repintará por sí sola — usar `Handle::spawn_periodic(50ms, …)`
10//! mientras el spinner esté visible para forzar redraw. O conectar
11//! el spinner a un `Tween` y leer su `progress()` desde la `view`.
12//!
13//! Diseño visual: arco de 270° con stroke variable (más grueso al
14//! frente del giro, más fino atrás) para dar sensación de aceleración.
15
16#![forbid(unsafe_code)]
17
18
19// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
20// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
21// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
22#![no_std]
23
24extern crate alloc;
25
26// El reloj de proceso (`OnceLock<Instant>`) vive en `std`, y este crate es
27// `no_std`: bajo la feature `std` se trae explícitamente. Ver [`origen`].
28#[cfg(feature = "std")]
29extern crate std;
30
31// El reloj del compositor: `web_time::Instant` bajo std, y SIN `now()` en el
32// perfil no_std — ahí el instante del fotograma entra por argumento, y un uso
33// equivocado es un error de compilación en vez de una animación congelada.
34#[cfg(feature = "std")]
35use llimphi_compositor::Instant;
36
37use llimphi_compositor::llimphi_layout::taffy::{
38 prelude::{percent, Size, Style},
39 Position,
40};
41use llimphi_compositor::peniko::kurbo::{Affine, Arc, Cap, Stroke};
42use llimphi_compositor::peniko::Color;
43use llimphi_compositor::View;
44
45/// El origen del reloj del spinner: **uno por proceso**, no uno por `View`.
46///
47/// El widget nació tomando `Instant::now()` al **construir** la vista, con el
48/// supuesto de que ese `View` sobrevive de un cuadro al siguiente. En el bucle
49/// Elm de llimphi no sobrevive: el redraw rearma el árbol entero llamando a
50/// `A::view(model)` en **cada** cuadro (`llimphi-ui/src/eventloop/vigia.rs`),
51/// así que el origen se reseteaba con el cuadro, `elapsed()` valía siempre ~0
52/// y el arco se pintaba clavado en el mismo ángulo. O sea: un spinner que no
53/// giraba, en el único uso que tiene un spinner.
54///
55/// Con un origen de proceso, el ángulo depende sólo de **cuándo se pinta** —
56/// que es lo que la doc del módulo prometía desde el principio («animado por
57/// reloj absoluto, no requiere ticks del modelo»).
58#[cfg(feature = "std")]
59fn origen() -> Instant {
60 static ORIGEN: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
61 *ORIGEN.get_or_init(Instant::now)
62}
63
64/// Ángulo (radianes) donde arranca el arco **en este instante**, para una
65/// velocidad dada en revoluciones por segundo. Público porque es la parte
66/// medible del widget: un test lo llama dos veces separadas en el tiempo y
67/// certifica que gira, sin rasterizar nada.
68#[cfg(feature = "std")]
69pub fn angulo_actual(speed_rev_per_sec: f32) -> f64 {
70 origen().elapsed().as_secs_f64() * speed_rev_per_sec as f64 * core::f64::consts::TAU
71}
72
73/// Construye el `View` que pinta un spinner circular animado dentro
74/// del rect del padre.
75///
76/// - `color`: tinte del arco (típico: `theme.accent`).
77/// - `stroke_width_ratio`: grosor del arco como fracción del lado
78/// menor (0.10 = 10%). Default razonable es `0.12`.
79/// - `speed_rev_per_sec`: revoluciones por segundo. Default `1.0`.
80// GATEADO A `std`: esta vista arranca su reloj con `Instant::now()`, que en el
81// perfil sin sistema NO EXISTE a propósito — allá el instante del fotograma
82// entra por argumento, y un uso equivocado debe ser un error de compilación, no
83// una animación congelada. Cuando la jaula tenga reloj de fotograma, el camino
84// es pasar el `Instant` por parámetro, no inventarle un `now()` al kernel.
85#[cfg(feature = "std")]
86pub fn spinner_view<Msg: Clone + 'static>(
87 color: Color,
88 stroke_width_ratio: f32,
89 speed_rev_per_sec: f32,
90) -> View<Msg> {
91 // El reloj es de PROCESO (ver [`origen`]), no del momento de construcción:
92 // la vista se rearma en cada cuadro y un origen por `View` dejaba el arco
93 // congelado. La closure se evalúa por cuadro y lee el ángulo de ahora.
94 let sw = stroke_width_ratio;
95 let speed = speed_rev_per_sec;
96 View::new(Style {
97 position: Position::Absolute,
98 size: Size {
99 width: percent(1.0_f32),
100 height: percent(1.0_f32),
101 },
102 ..Default::default()
103 })
104 .paint_with(move |scene, _ts, rect| {
105 let side = rect.w.min(rect.h) as f64;
106 if side <= 0.0 {
107 return;
108 }
109 let cx = rect.x as f64 + rect.w as f64 * 0.5;
110 let cy = rect.y as f64 + rect.h as f64 * 0.5;
111 let stroke_w = (side * sw as f64).max(1.0);
112 let radius = (side - stroke_w) * 0.5;
113 // Ángulo de inicio del arco — gira completamente cada `1/speed` s.
114 let theta0 = angulo_actual(speed);
115 // Arco de 270° (= 3π/2 rad) — la "abertura" sugiere movimiento.
116 let sweep = core::f64::consts::PI * 1.5;
117 let arc = Arc::new((cx, cy), (radius, radius), theta0, sweep, 0.0);
118 let stroke = Stroke::new(stroke_w).with_caps(Cap::Round);
119 scene.stroke(&stroke, Affine::IDENTITY, color, None, &arc);
120 })
121 // Progreso **indeterminado**: rol sin `value`. Es la diferencia que un
122 // lector enuncia como "cargando" en vez de un porcentaje.
123 .role(llimphi_compositor::Role::ProgressBar)
124 .aria_label("Cargando")
125}
126
127
128#[cfg(all(test, feature = "std"))]
129mod pruebas {
130 use super::*;
131
132 /// **El origen del reloj es del proceso, no de la construcción.**
133 ///
134 /// Es la propiedad que hacía la diferencia entre un spinner que gira y uno
135 /// clavado: el bucle de llimphi rearma el árbol de vistas en cada cuadro,
136 /// así que un origen tomado al construir vale siempre "recién" y el ángulo
137 /// no se mueve nunca.
138 #[test]
139 fn el_origen_no_se_mueve_entre_llamadas() {
140 let a = origen();
141 std::thread::sleep(core::time::Duration::from_millis(20));
142 let b = origen();
143 assert_eq!(a, b, "el origen del spinner se reinició entre dos llamadas");
144 }
145
146 /// Y el ángulo sí avanza con el tiempo de pared: dos pinturas separadas
147 /// dibujan el arco en lugares distintos.
148 #[test]
149 fn el_angulo_avanza_con_el_tiempo() {
150 let a = angulo_actual(1.0);
151 std::thread::sleep(core::time::Duration::from_millis(30));
152 let b = angulo_actual(1.0);
153 assert!(
154 b - a > 0.1,
155 "a 1 rev/s, 30 ms tienen que mover el arco: {a} → {b}"
156 );
157 }
158}