//! `llimphi-widget-fugaces` — la máquina de los **controles fantasma**. //! //! Un fantasma es un control que **no ocupa lugar mientras no tiene nada que //! decir**: en reposo se ve uno solo, turnándose; al acercar el puntero salen //! todos para tocarlos. Nació en el cabezal de shuma dentro de `pata` y se //! extrae acá porque el patrón no es de la barra del escritorio: sirve en //! cualquier lugar donde haya **más acciones o estados que píxeles**, que es la //! definición de una barra de herramientas contextual. //! //! ## Qué es de este crate y qué es de quien lo usa //! //! | Acá | En el consumidor | //! |---|---| //! | la jerarquía de voz ([`Nivel`]) | qué nivel tiene cada fantasma hoy | //! | el turno rotativo ([`avanzar_idx`], [`elegir_visibles`]) | el reloj | //! | el asiento aprendido ([`Uso`], [`orden_asientos`]) | dónde persiste | //! | el congelado bajo el puntero ([`Freeze`], [`congelar`]) | cuándo entra el puntero | //! | el armado de la franja ([`slots`]) | el contenedor y el dibujo de cada ícono | //! //! **No pinta íconos y no sabe qué representan.** El consumidor implementa //! [`Fantasma`] sobre su propio enum, arma sus vistas y elige el contenedor — //! `pata` lo monta como overlay absoluto a la derecha del input, `nahual` como //! banda dentro de la barra de herramientas. Esa es la razón de que [`slots`] //! devuelva un `Vec` y no una `View`: el contenedor es del que llama. //! //! ## Las tres cosas que no son obvias //! //! 1. **El orden se congela bajo el mouse.** Un click aprende (mueve el //! asiento) pero no puede recolocar los íconos que la mano está recorriendo. //! Por eso existe [`Freeze`], y por eso guarda también la **membresía**: un //! fantasma que aparece a mitad del hover no se inserta, y uno que se va //! deja un **hueco de su ancho** para que nadie se corra. //! 2. **El turno nunca deja la franja muerta.** Sin nada saliente, el turno lo //! ocupa el fondo ambiental, en voz baja ([`AMBIENTE_ALPHA`]) y a paso //! [`AMBIENTE_TURNOS`] veces más lento. //! 3. **Un acuse no espera turno.** [`Nivel::Evento`] dura segundos; una //! rotación dura [`ROT_US`]. Hacerlo esperar es no mostrarlo. #![forbid(unsafe_code)] // El crate es `no_std` incluso con `persistencia` prendida: ahí el módulo // [`uso`] trae `std` para sí solo. Así la mitad que va a wawa no arrastra nada. #![no_std] extern crate alloc; use alloc::vec::Vec; use llimphi_compositor::llimphi_layout::taffy::{ prelude::{auto, length, Size, Style}, AlignItems, JustifyContent, }; use llimphi_compositor::View; #[cfg(feature = "persistencia")] mod uso; #[cfg(feature = "persistencia")] pub use uso::Uso; // ============================================================================= // Identidad // ============================================================================= /// Lo que este crate necesita saber de un fantasma: **cómo se llama para /// persistir** y **cuánto vale de arranque**. /// /// La clave es `&'static str` y no el discriminante del enum a propósito: el /// registro de uso sobrevive a que alguien reordene las variantes o retire una. /// Los nombres de fantasmas retirados quedan huérfanos en el archivo y se /// ignoran solos. pub trait Fantasma: Copy + PartialEq + core::fmt::Debug { /// Nombre estable para persistir el uso. **No cambia** aunque el enum se /// reordene: es la clave del registro en disco. fn clave(self) -> &'static str; /// **Prior de utilidad**: la estimación inicial de cuánto le sirve al /// usuario (más alto = más útil = se sienta más al final de la fila). Es el /// arranque del orden aprendido; cada click real lo corrige vía [`Uso`]. fn prior(self) -> f32; } // ============================================================================= // Jerarquía de voz // ============================================================================= /// **Cuánta voz tiene** un fantasma cuando el puntero está lejos. /// /// El motivo de que sean cinco y no dos bits (`saliente`/`crítico`) es una /// medición: con dos, un acuse que dura 2 s —el volumen que acabás de mover— /// tenía que ganarle el turno de 4 s al clima, y casi siempre lo perdía. Y lo /// que nunca es saliente no se veía **jamás** sin pasar el mouse. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Nivel { /// Muda: sólo aparece en el reveal. Es el nivel de las **acciones** puras /// (capturar la pantalla no es un estado que valga anunciar). Nada, /// Sin nada que decir, pero mirable: entra al **turno de fondo**, el que /// ocupa la franja cuando ninguna leve está saliente. Se pinta atenuada y /// rota más lento — es la franja respirando, no un aviso. Ambiente, /// Saliente por un estado que **dura**: se turna con las otras leves cada /// [`ROT_US`]. Leve, /// **Acuse transitorio** de segundos. No espera turno — se ve YA y se va /// solo al cerrarse su ventana. Es lo único que justifica interrumpir la /// rotación. Evento, /// Urgente y persistente: sale fija, y todas las críticas juntas. Critica, } /// Cadencia del turno de los fantasmas **leves** (µs): con varios salientes a /// la vez no se apilan fijos — se turnan de a uno. pub const ROT_US: u64 = 4_000_000; /// Cuántos turnos de [`ROT_US`] dura la cara de un fantasma **ambiental**: el /// fondo respira más lento que un aviso. pub const AMBIENTE_TURNOS: usize = 2; /// Opacidad relativa de un fantasma **ambiental** fuera del reveal: presente /// pero en voz baja — un saliente de verdad tiene que leerse más fuerte. pub const AMBIENTE_ALPHA: f32 = 0.55; // ============================================================================= // El turno // ============================================================================= /// Avanza el turno de los fantasmas leves cada [`ROT_US`]. `congelar` (reveal /// activo o ícono pinneado por hover) detiene el turno **y** re-estampa el /// reloj — así el orden no cambia bajo el mouse y al soltarlo el próximo giro /// tarda el intervalo completo. Devuelve `true` si rotó (hay que repintar). pub fn avanzar_idx(idx: &mut usize, reloj: &mut u64, ahora_us: u64, congelar: bool) -> bool { if congelar { *reloj = ahora_us; return false; } if ahora_us.saturating_sub(*reloj) >= ROT_US { *idx = idx.wrapping_add(1); *reloj = ahora_us; true } else { false } } /// Regla de visibilidad FUERA del reveal, pura y testeable. `cands` viene en /// orden **canónico** (el del enum, no el de asiento) como `(id, nivel)`: /// /// - las **críticas** van todas, fijas — lo urgente manda, no rota; /// - los **eventos** van todos, fijos y sin esperar turno; /// - de las **leves** se ve UNA, turnándose por `idx`; /// - si no hay ninguna leve, el turno lo ocupa el **fondo ambiental** (una a la /// vez, a paso [`AMBIENTE_TURNOS`] veces más lento) — así la franja nunca /// queda muerta; /// - el **pinneado** por hover se queda aunque su condición haya caído. pub fn elegir_visibles(cands: &[(F, Nivel)], idx: usize, pin: Option) -> Vec { let del = |n: Nivel| -> Vec { cands.iter().filter(|(_, k)| *k == n).map(|(f, _)| *f).collect() }; let mut vis = del(Nivel::Critica); for f in del(Nivel::Evento) { if !vis.contains(&f) { vis.push(f); } } let leves = del(Nivel::Leve); let (pool, paso) = if leves.is_empty() { (del(Nivel::Ambiente), idx / AMBIENTE_TURNOS) } else { (leves, idx) }; if !pool.is_empty() { let f = pool[paso % pool.len()]; if !vis.contains(&f) { vis.push(f); } } if let Some(p) = pin { if !vis.contains(&p) && cands.iter().any(|(f, _)| *f == p) { vis.push(p); } } vis } // ============================================================================= // El asiento aprendido // ============================================================================= /// Puntaje de asiento: `prior + 2·√usos`. Los primeros clicks mueven el asiento /// rápido y después satura — el orden queda «más o menos fijo», que es lo que /// hace que la mano lo pueda memorizar. /// /// La raíz sale de `libm` y no de `f32::sqrt` **también con `std`**: el puntaje /// tiene que dar el mismo número en los cuatro targets, o el asiento cambia de /// orden según dónde compilaste. pub fn score(f: F, usos: u32) -> f32 { f.prior() + 2.0 * libm::sqrtf(usos as f32) } /// El **orden de asientos** vigente: `todos` ordenados por puntaje ascendente /// (el más útil/usado queda **último** = al final de la fila). Orden estable: a /// igual puntaje sobrevive el orden canónico de `todos`. /// /// Es el snapshot que el consumidor **congela** cuando el puntero se acerca. pub fn orden_asientos(todos: &[F], usos: impl Fn(F) -> u32) -> Vec { let mut orden = todos.to_vec(); orden.sort_by(|a, b| { score(*a, usos(*a)) .partial_cmp(&score(*b, usos(*b))) .unwrap_or(core::cmp::Ordering::Equal) }); orden } // ============================================================================= // El congelado // ============================================================================= /// **Snapshot congelado** de los fugaces mientras el puntero anda cerca de la /// zona (hover o pin): estampa TODO lo que decide posiciones para que nada se /// recoloque bajo el mouse. Se estampa al entrar el puntero y se libera cuando /// la zona se esfuma del todo. #[derive(Clone, Debug)] pub struct Freeze { /// Orden de asientos vigente al congelar ([`orden_asientos`]). pub orden: Vec, /// Los que estaban visibles al congelar (van al frente): el split /// frente/fondo NO se recomputa con el pin/salience vivos. pub visibles: Vec, /// Los candidatos presentes al congelar: uno cuyo dato llega a mitad del /// hover NO se inserta (correría a los demás); uno cuyo dato se va deja un /// hueco del mismo ancho (nadie se mueve). pub presentes: Vec, /// Reloj (µs) estampado al congelar: fija la cara de los íconos /// multifacéticos, que si no alternarían glifo bajo el mouse. pub reloj_us: u64, } /// Computa el [`Freeze`] con los datos vigentes. `cands` va en orden canónico. pub fn congelar( todos: &[F], cands: &[(F, Nivel)], usos: impl Fn(F) -> u32, idx: usize, pin: Option, ahora_us: u64, ) -> Freeze { Freeze { orden: orden_asientos(todos, usos), visibles: elegir_visibles(cands, idx, pin), presentes: cands.iter().map(|(f, _)| *f).collect(), reloj_us: ahora_us, } } // ============================================================================= // El armado de la franja // ============================================================================= /// Cómo se resuelve la opacidad y el orden de cada slot. Lo que el consumidor /// **no** puede decidir por su cuenta sin repetir la política entera. pub struct Franja<'a, F> { /// Los que se ven sin reveal, ya resueltos (congelados o vivos): la misma /// lista que dimensionó la zona. pub visibles: &'a [F], /// El snapshot congelado, si el puntero anda cerca. pub fijo: Option<&'a Freeze>, /// El pinneado por hover: opacidad plena, pase lo que pase. pub pin: Option, /// Opacidad base de los salientes (el consumidor la baja cuando algo se les /// acerca — en `pata`, el texto que se tipea). `1.0` si no aplica. pub base: f32, /// Opacidad del reveal (0 = puntero lejos, 1 = todos a la vista). pub revelar_alpha: f32, /// Alto del hueco que deja un fantasma ausente durante el congelado. pub alto_hueco: f32, } /// Ordena, rellena y opaca los candidatos: **la política de la franja**, sin /// contenedor. Devuelve los slots ya listos, de izquierda a derecha, para que /// el consumidor los meta en el `Row` que quiera. /// /// Con `fijo` presente (puntero cerca) TODO queda estampado del snapshot: /// orden, split frente/fondo, membresía y huecos — nada se mueve bajo el mouse. /// Sin él manda el asiento aprendido vivo. /// /// El **orden es pegajoso**: los visibles van al FRENTE (el final de la fila) y /// en reveal los demás aparecen antes que ellos. Así el que ya estaba a la /// vista se queda donde apareció cuando llegan los otros, y al esfumarse el /// reveal todos vuelven a su asiento. pub fn slots( mut cands: Vec<(F, Nivel, View)>, franja: &Franja<'_, F>, usos: impl Fn(F) -> u32, ancho_slot: impl Fn(F) -> f32, ) -> Vec> { // ASIENTO: congelado si el puntero anda cerca, vivo si no. match franja.fijo { Some(fijo) => cands.sort_by_key(|c| { fijo.orden.iter().position(|f| *f == c.0).unwrap_or(usize::MAX) }), None => cands.sort_by(|a, b| { score(a.0, usos(a.0)) .partial_cmp(&score(b.0, usos(b.0))) .unwrap_or(core::cmp::Ordering::Equal) }), } // MEMBRESÍA congelada: el que aparece a mitad del hover no se inserta; el // que se fue deja un hueco de su ancho. if let Some(fijo) = franja.fijo { cands.retain(|c| fijo.presentes.contains(&c.0)); for id in &fijo.presentes { if !cands.iter().any(|c| c.0 == *id) { let pos = fijo.orden.iter().position(|f| f == id).unwrap_or(usize::MAX); let hueco = View::new(Style { size: Size { width: length(ancho_slot(*id)), height: length(franja.alto_hueco), }, flex_shrink: 0.0, ..Default::default() }); let at = cands .iter() .position(|c| { fijo.orden.iter().position(|f| *f == c.0).unwrap_or(usize::MAX) > pos }) .unwrap_or(cands.len()); cands.insert(at, (*id, Nivel::Nada, hueco)); } } } let revelando = franja.revelar_alpha > 0.01; let mut fondo: Vec> = Vec::new(); let mut frente: Vec> = Vec::new(); for (id, nivel, v) in cands { // Cada ícono en su **slot de ancho fijo**: el ancho de la fila y la // posición de cada vecino no dependen de la cara vigente. let slot = View::new(Style { size: Size { width: length(ancho_slot(id)), height: auto() }, flex_shrink: 0.0, align_items: Some(AlignItems::CENTER), justify_content: Some(JustifyContent::CENTER), ..Default::default() }) .children(alloc::vec![v]); let pinneado = franja.pin == Some(id); if franja.visibles.contains(&id) { // Visible ∧ ¬saliente sólo pasa pinneado. El pin fuerza opacidad // plena; si no, el fade normal. El fondo ambiental va en voz baja // mientras nadie mire — con el reveal se muestra a la par. let a = if pinneado { 1.0 } else { franja.base.max(franja.revelar_alpha) }; let a = if nivel == Nivel::Ambiente && !pinneado && !revelando { a * AMBIENTE_ALPHA } else { a }; frente.push(slot.alpha(a)); } else if revelando { fondo.push(slot.alpha(franja.revelar_alpha)); } } fondo.into_iter().chain(frente).collect() } #[cfg(test)] mod tests;