tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
1//! `llimphi-widget-fugaces` — la máquina de los **controles fantasma**.
2//!
3//! Un fantasma es un control que **no ocupa lugar mientras no tiene nada que
4//! decir**: en reposo se ve uno solo, turnándose; al acercar el puntero salen
5//! todos para tocarlos. Nació en el cabezal de shuma dentro de `pata` y se
6//! extrae acá porque el patrón no es de la barra del escritorio: sirve en
7//! cualquier lugar donde haya **más acciones o estados que píxeles**, que es la
8//! definición de una barra de herramientas contextual.
9//!
10//! ## Qué es de este crate y qué es de quien lo usa
11//!
12//! | Acá | En el consumidor |
13//! |---|---|
14//! | la jerarquía de voz ([`Nivel`]) | qué nivel tiene cada fantasma hoy |
15//! | el turno rotativo ([`avanzar_idx`], [`elegir_visibles`]) | el reloj |
16//! | el asiento aprendido ([`Uso`], [`orden_asientos`]) | dónde persiste |
17//! | el congelado bajo el puntero ([`Freeze`], [`congelar`]) | cuándo entra el puntero |
18//! | el armado de la franja ([`slots`]) | el contenedor y el dibujo de cada ícono |
19//!
20//! **No pinta íconos y no sabe qué representan.** El consumidor implementa
21//! [`Fantasma`] sobre su propio enum, arma sus vistas y elige el contenedor —
22//! `pata` lo monta como overlay absoluto a la derecha del input, `nahual` como
23//! banda dentro de la barra de herramientas. Esa es la razón de que [`slots`]
24//! devuelva un `Vec<View>` y no una `View`: el contenedor es del que llama.
25//!
26//! ## Las tres cosas que no son obvias
27//!
28//! 1. **El orden se congela bajo el mouse.** Un click aprende (mueve el
29//! asiento) pero no puede recolocar los íconos que la mano está recorriendo.
30//! Por eso existe [`Freeze`], y por eso guarda también la **membresía**: un
31//! fantasma que aparece a mitad del hover no se inserta, y uno que se va
32//! deja un **hueco de su ancho** para que nadie se corra.
33//! 2. **El turno nunca deja la franja muerta.** Sin nada saliente, el turno lo
34//! ocupa el fondo ambiental, en voz baja ([`AMBIENTE_ALPHA`]) y a paso
35//! [`AMBIENTE_TURNOS`] veces más lento.
36//! 3. **Un acuse no espera turno.** [`Nivel::Evento`] dura segundos; una
37//! rotación dura [`ROT_US`]. Hacerlo esperar es no mostrarlo.
38
39#![forbid(unsafe_code)]
40// El crate es `no_std` incluso con `persistencia` prendida: ahí el módulo
41// [`uso`] trae `std` para sí solo. Así la mitad que va a wawa no arrastra nada.
42#![no_std]
43
44extern crate alloc;
45
46use alloc::vec::Vec;
47
48use llimphi_compositor::llimphi_layout::taffy::{
49 prelude::{auto, length, Size, Style},
50 AlignItems, JustifyContent,
51};
52use llimphi_compositor::View;
53
54#[cfg(feature = "persistencia")]
55mod uso;
56#[cfg(feature = "persistencia")]
57pub use uso::Uso;
58
59// =============================================================================
60// Identidad
61// =============================================================================
62
63/// Lo que este crate necesita saber de un fantasma: **cómo se llama para
64/// persistir** y **cuánto vale de arranque**.
65///
66/// La clave es `&'static str` y no el discriminante del enum a propósito: el
67/// registro de uso sobrevive a que alguien reordene las variantes o retire una.
68/// Los nombres de fantasmas retirados quedan huérfanos en el archivo y se
69/// ignoran solos.
70pub trait Fantasma: Copy + PartialEq + core::fmt::Debug {
71 /// Nombre estable para persistir el uso. **No cambia** aunque el enum se
72 /// reordene: es la clave del registro en disco.
73 fn clave(self) -> &'static str;
74
75 /// **Prior de utilidad**: la estimación inicial de cuánto le sirve al
76 /// usuario (más alto = más útil = se sienta más al final de la fila). Es el
77 /// arranque del orden aprendido; cada click real lo corrige vía [`Uso`].
78 fn prior(self) -> f32;
79}
80
81// =============================================================================
82// Jerarquía de voz
83// =============================================================================
84
85/// **Cuánta voz tiene** un fantasma cuando el puntero está lejos.
86///
87/// El motivo de que sean cinco y no dos bits (`saliente`/`crítico`) es una
88/// medición: con dos, un acuse que dura 2 s —el volumen que acabás de mover—
89/// tenía que ganarle el turno de 4 s al clima, y casi siempre lo perdía. Y lo
90/// que nunca es saliente no se veía **jamás** sin pasar el mouse.
91#[derive(Clone, Copy, PartialEq, Eq, Debug)]
92pub enum Nivel {
93 /// Muda: sólo aparece en el reveal. Es el nivel de las **acciones** puras
94 /// (capturar la pantalla no es un estado que valga anunciar).
95 Nada,
96 /// Sin nada que decir, pero mirable: entra al **turno de fondo**, el que
97 /// ocupa la franja cuando ninguna leve está saliente. Se pinta atenuada y
98 /// rota más lento — es la franja respirando, no un aviso.
99 Ambiente,
100 /// Saliente por un estado que **dura**: se turna con las otras leves cada
101 /// [`ROT_US`].
102 Leve,
103 /// **Acuse transitorio** de segundos. No espera turno — se ve YA y se va
104 /// solo al cerrarse su ventana. Es lo único que justifica interrumpir la
105 /// rotación.
106 Evento,
107 /// Urgente y persistente: sale fija, y todas las críticas juntas.
108 Critica,
109}
110
111/// Cadencia del turno de los fantasmas **leves** (µs): con varios salientes a
112/// la vez no se apilan fijos — se turnan de a uno.
113pub const ROT_US: u64 = 4_000_000;
114
115/// Cuántos turnos de [`ROT_US`] dura la cara de un fantasma **ambiental**: el
116/// fondo respira más lento que un aviso.
117pub const AMBIENTE_TURNOS: usize = 2;
118
119/// Opacidad relativa de un fantasma **ambiental** fuera del reveal: presente
120/// pero en voz baja — un saliente de verdad tiene que leerse más fuerte.
121pub const AMBIENTE_ALPHA: f32 = 0.55;
122
123// =============================================================================
124// El turno
125// =============================================================================
126
127/// Avanza el turno de los fantasmas leves cada [`ROT_US`]. `congelar` (reveal
128/// activo o ícono pinneado por hover) detiene el turno **y** re-estampa el
129/// reloj — así el orden no cambia bajo el mouse y al soltarlo el próximo giro
130/// tarda el intervalo completo. Devuelve `true` si rotó (hay que repintar).
131pub fn avanzar_idx(idx: &mut usize, reloj: &mut u64, ahora_us: u64, congelar: bool) -> bool {
132 if congelar {
133 *reloj = ahora_us;
134 return false;
135 }
136 if ahora_us.saturating_sub(*reloj) >= ROT_US {
137 *idx = idx.wrapping_add(1);
138 *reloj = ahora_us;
139 true
140 } else {
141 false
142 }
143}
144
145/// Regla de visibilidad FUERA del reveal, pura y testeable. `cands` viene en
146/// orden **canónico** (el del enum, no el de asiento) como `(id, nivel)`:
147///
148/// - las **críticas** van todas, fijas — lo urgente manda, no rota;
149/// - los **eventos** van todos, fijos y sin esperar turno;
150/// - de las **leves** se ve UNA, turnándose por `idx`;
151/// - si no hay ninguna leve, el turno lo ocupa el **fondo ambiental** (una a la
152/// vez, a paso [`AMBIENTE_TURNOS`] veces más lento) — así la franja nunca
153/// queda muerta;
154/// - el **pinneado** por hover se queda aunque su condición haya caído.
155pub fn elegir_visibles<F: Fantasma>(cands: &[(F, Nivel)], idx: usize, pin: Option<F>) -> Vec<F> {
156 let del = |n: Nivel| -> Vec<F> {
157 cands.iter().filter(|(_, k)| *k == n).map(|(f, _)| *f).collect()
158 };
159 let mut vis = del(Nivel::Critica);
160 for f in del(Nivel::Evento) {
161 if !vis.contains(&f) {
162 vis.push(f);
163 }
164 }
165 let leves = del(Nivel::Leve);
166 let (pool, paso) = if leves.is_empty() {
167 (del(Nivel::Ambiente), idx / AMBIENTE_TURNOS)
168 } else {
169 (leves, idx)
170 };
171 if !pool.is_empty() {
172 let f = pool[paso % pool.len()];
173 if !vis.contains(&f) {
174 vis.push(f);
175 }
176 }
177 if let Some(p) = pin {
178 if !vis.contains(&p) && cands.iter().any(|(f, _)| *f == p) {
179 vis.push(p);
180 }
181 }
182 vis
183}
184
185// =============================================================================
186// El asiento aprendido
187// =============================================================================
188
189/// Puntaje de asiento: `prior + 2·√usos`. Los primeros clicks mueven el asiento
190/// rápido y después satura — el orden queda «más o menos fijo», que es lo que
191/// hace que la mano lo pueda memorizar.
192///
193/// La raíz sale de `libm` y no de `f32::sqrt` **también con `std`**: el puntaje
194/// tiene que dar el mismo número en los cuatro targets, o el asiento cambia de
195/// orden según dónde compilaste.
196pub fn score<F: Fantasma>(f: F, usos: u32) -> f32 {
197 f.prior() + 2.0 * libm::sqrtf(usos as f32)
198}
199
200/// El **orden de asientos** vigente: `todos` ordenados por puntaje ascendente
201/// (el más útil/usado queda **último** = al final de la fila). Orden estable: a
202/// igual puntaje sobrevive el orden canónico de `todos`.
203///
204/// Es el snapshot que el consumidor **congela** cuando el puntero se acerca.
205pub fn orden_asientos<F: Fantasma>(todos: &[F], usos: impl Fn(F) -> u32) -> Vec<F> {
206 let mut orden = todos.to_vec();
207 orden.sort_by(|a, b| {
208 score(*a, usos(*a))
209 .partial_cmp(&score(*b, usos(*b)))
210 .unwrap_or(core::cmp::Ordering::Equal)
211 });
212 orden
213}
214
215// =============================================================================
216// El congelado
217// =============================================================================
218
219/// **Snapshot congelado** de los fugaces mientras el puntero anda cerca de la
220/// zona (hover o pin): estampa TODO lo que decide posiciones para que nada se
221/// recoloque bajo el mouse. Se estampa al entrar el puntero y se libera cuando
222/// la zona se esfuma del todo.
223#[derive(Clone, Debug)]
224pub struct Freeze<F> {
225 /// Orden de asientos vigente al congelar ([`orden_asientos`]).
226 pub orden: Vec<F>,
227 /// Los que estaban visibles al congelar (van al frente): el split
228 /// frente/fondo NO se recomputa con el pin/salience vivos.
229 pub visibles: Vec<F>,
230 /// Los candidatos presentes al congelar: uno cuyo dato llega a mitad del
231 /// hover NO se inserta (correría a los demás); uno cuyo dato se va deja un
232 /// hueco del mismo ancho (nadie se mueve).
233 pub presentes: Vec<F>,
234 /// Reloj (µs) estampado al congelar: fija la cara de los íconos
235 /// multifacéticos, que si no alternarían glifo bajo el mouse.
236 pub reloj_us: u64,
237}
238
239/// Computa el [`Freeze`] con los datos vigentes. `cands` va en orden canónico.
240pub fn congelar<F: Fantasma>(
241 todos: &[F],
242 cands: &[(F, Nivel)],
243 usos: impl Fn(F) -> u32,
244 idx: usize,
245 pin: Option<F>,
246 ahora_us: u64,
247) -> Freeze<F> {
248 Freeze {
249 orden: orden_asientos(todos, usos),
250 visibles: elegir_visibles(cands, idx, pin),
251 presentes: cands.iter().map(|(f, _)| *f).collect(),
252 reloj_us: ahora_us,
253 }
254}
255
256// =============================================================================
257// El armado de la franja
258// =============================================================================
259
260/// Cómo se resuelve la opacidad y el orden de cada slot. Lo que el consumidor
261/// **no** puede decidir por su cuenta sin repetir la política entera.
262pub struct Franja<'a, F> {
263 /// Los que se ven sin reveal, ya resueltos (congelados o vivos): la misma
264 /// lista que dimensionó la zona.
265 pub visibles: &'a [F],
266 /// El snapshot congelado, si el puntero anda cerca.
267 pub fijo: Option<&'a Freeze<F>>,
268 /// El pinneado por hover: opacidad plena, pase lo que pase.
269 pub pin: Option<F>,
270 /// Opacidad base de los salientes (el consumidor la baja cuando algo se les
271 /// acerca — en `pata`, el texto que se tipea). `1.0` si no aplica.
272 pub base: f32,
273 /// Opacidad del reveal (0 = puntero lejos, 1 = todos a la vista).
274 pub revelar_alpha: f32,
275 /// Alto del hueco que deja un fantasma ausente durante el congelado.
276 pub alto_hueco: f32,
277}
278
279/// Ordena, rellena y opaca los candidatos: **la política de la franja**, sin
280/// contenedor. Devuelve los slots ya listos, de izquierda a derecha, para que
281/// el consumidor los meta en el `Row` que quiera.
282///
283/// Con `fijo` presente (puntero cerca) TODO queda estampado del snapshot:
284/// orden, split frente/fondo, membresía y huecos — nada se mueve bajo el mouse.
285/// Sin él manda el asiento aprendido vivo.
286///
287/// El **orden es pegajoso**: los visibles van al FRENTE (el final de la fila) y
288/// en reveal los demás aparecen antes que ellos. Así el que ya estaba a la
289/// vista se queda donde apareció cuando llegan los otros, y al esfumarse el
290/// reveal todos vuelven a su asiento.
291pub fn slots<F: Fantasma, Msg: Clone + 'static>(
292 mut cands: Vec<(F, Nivel, View<Msg>)>,
293 franja: &Franja<'_, F>,
294 usos: impl Fn(F) -> u32,
295 ancho_slot: impl Fn(F) -> f32,
296) -> Vec<View<Msg>> {
297 // ASIENTO: congelado si el puntero anda cerca, vivo si no.
298 match franja.fijo {
299 Some(fijo) => cands.sort_by_key(|c| {
300 fijo.orden.iter().position(|f| *f == c.0).unwrap_or(usize::MAX)
301 }),
302 None => cands.sort_by(|a, b| {
303 score(a.0, usos(a.0))
304 .partial_cmp(&score(b.0, usos(b.0)))
305 .unwrap_or(core::cmp::Ordering::Equal)
306 }),
307 }
308
309 // MEMBRESÍA congelada: el que aparece a mitad del hover no se inserta; el
310 // que se fue deja un hueco de su ancho.
311 if let Some(fijo) = franja.fijo {
312 cands.retain(|c| fijo.presentes.contains(&c.0));
313 for id in &fijo.presentes {
314 if !cands.iter().any(|c| c.0 == *id) {
315 let pos = fijo.orden.iter().position(|f| f == id).unwrap_or(usize::MAX);
316 let hueco = View::new(Style {
317 size: Size {
318 width: length(ancho_slot(*id)),
319 height: length(franja.alto_hueco),
320 },
321 flex_shrink: 0.0,
322 ..Default::default()
323 });
324 let at = cands
325 .iter()
326 .position(|c| {
327 fijo.orden.iter().position(|f| *f == c.0).unwrap_or(usize::MAX) > pos
328 })
329 .unwrap_or(cands.len());
330 cands.insert(at, (*id, Nivel::Nada, hueco));
331 }
332 }
333 }
334
335 let revelando = franja.revelar_alpha > 0.01;
336 let mut fondo: Vec<View<Msg>> = Vec::new();
337 let mut frente: Vec<View<Msg>> = Vec::new();
338 for (id, nivel, v) in cands {
339 // Cada ícono en su **slot de ancho fijo**: el ancho de la fila y la
340 // posición de cada vecino no dependen de la cara vigente.
341 let slot = View::new(Style {
342 size: Size { width: length(ancho_slot(id)), height: auto() },
343 flex_shrink: 0.0,
344 align_items: Some(AlignItems::CENTER),
345 justify_content: Some(JustifyContent::CENTER),
346 ..Default::default()
347 })
348 .children(alloc::vec![v]);
349 let pinneado = franja.pin == Some(id);
350 if franja.visibles.contains(&id) {
351 // Visible ∧ ¬saliente sólo pasa pinneado. El pin fuerza opacidad
352 // plena; si no, el fade normal. El fondo ambiental va en voz baja
353 // mientras nadie mire — con el reveal se muestra a la par.
354 let a = if pinneado { 1.0 } else { franja.base.max(franja.revelar_alpha) };
355 let a = if nivel == Nivel::Ambiente && !pinneado && !revelando {
356 a * AMBIENTE_ALPHA
357 } else {
358 a
359 };
360 frente.push(slot.alpha(a));
361 } else if revelando {
362 fondo.push(slot.alpha(franja.revelar_alpha));
363 }
364 }
365 fondo.into_iter().chain(frente).collect()
366}
367
368#[cfg(test)]
369mod tests;