tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main widgets/fugaces/src/lib.rs Rust · 369 siq'ikuna · 15.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

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;