aura-gui 0.3.5

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Estado reactivo, persistencia en disco, componentes de closure, animaciones y tareas asíncronas en `Ui`.

use crate::core::{Animation, CancelToken, Easing, JobHandle, Latest, Lerp, ms};
use crate::view::state::{PersistValue, State};
use crate::view::ui::Ui;
use std::time::{Duration, Instant};

/// Segundos que dura la fase de `phase`: un día, para que no se reinicie mientras la app está abierta.
const PHASE_SECONDS: f32 = 86_400.0;

struct TaskSlot<D, R> {
    dep: Option<D>,
    latest: Latest,
    value: Option<R>,
}

impl<'a> Ui<'a> {
    // ---------- Estado y persistencia ----------

    /// Estado de la app por clave estable: `ui.state("clics", 0u32)`.
    ///
    /// La primera llamada crea el valor; las siguientes devuelven el mismo. El tipo debe coincidir con el de la
    /// primera llamada (si no, el error dice la clave y el tipo).
    pub fn state<T: 'static>(&mut self, key: &'static str, default: T) -> State<T> {
        self.state.get_or_insert(key, default)
    }

    /// Como [`Self::state`], pero el valor inicial solo se construye si la clave no existía.
    ///
    /// Úsalo para estados caros (`App::default()`, una lista grande): `ui.state_with("app", App::default)`.
    pub fn state_with<T: 'static>(&mut self, key: &'static str, init: impl FnOnce() -> T) -> State<T> {
        self.state.get_or_insert_with(key, init)
    }

    /// Estado que se carga automáticamente del archivo `prefs.json` de la app al iniciar y se guarda en
    /// segundo plano cada vez que cambia (`write`, `update`, `set`, `toggle`).
    pub fn persisted<T: PersistValue>(&mut self, key: &'static str, default: T) -> State<T> {
        let background = self.entities.background.clone();
        self.state.persisted(key, || default, &background)
    }

    /// Como [`Self::persisted`], pero el valor por defecto solo se construye si no estaba en memoria ni en disco.
    pub fn persisted_with<T: PersistValue>(&mut self, key: &'static str, init: impl FnOnce() -> T) -> State<T> {
        let background = self.entities.background.clone();
        self.state.persisted(key, init, &background)
    }

    /// Componente con estado propio, sin `impl`: recibe la parte mutable y un asa para los manejadores.
    pub fn component<T: 'static, R>(
        &mut self,
        key: &'static str,
        init: impl FnOnce() -> T,
        build: impl FnOnce(&mut Ui, &mut T, State<T>) -> R,
    ) -> R {
        let state = self.state.get_or_insert_with(key, init);
        state.with_mut(|value| build(self, value, state.clone()))
    }

    /// Como [`Self::component`], pero la inicialización recibe `ui` (para crear entidades o leer el tema).
    pub fn component_with<T: 'static, R>(
        &mut self,
        key: &'static str,
        init: impl FnOnce(&mut Ui) -> T,
        build: impl FnOnce(&mut Ui, &mut T, State<T>) -> R,
    ) -> R {
        let state = match self.state.get::<T>(key) {
            Some(state) => state,
            None => {
                let value = init(self);
                self.state.insert(key, value)
            }
        };
        state.with_mut(|value| build(self, value, state.clone()))
    }

    // ---------- Animaciones ----------

    /// Progreso (0 a 1) de una animación con nombre; mientras corre pide más frames.
    pub fn animate(&mut self, id: &str, animation: Animation) -> f32 {
        let (progress, running) = self.entities.animations.progress(id, animation, Instant::now());
        if running {
            self.entities.timers.request_animation_frame();
        }
        progress
    }

    /// Interpola entre dos valores (colores, posiciones, tamaños) con una animación con nombre.
    pub fn animate_between<L: Lerp>(&mut self, id: &str, animation: Animation, from: L, to: L) -> L {
        from.lerp(to, self.animate(id, animation))
    }

    /// Como `transition` de CSS con muelle suave por defecto (`300 ms`, `Easing::SPRING`): el valor se desliza
    /// hacia `target` cada vez que este cambia, partiendo de donde iba. Solo pide frames mientras se mueve.
    pub fn transition(&mut self, id: &str, target: f32) -> f32 {
        self.transition_with(id, target, Animation::new(ms(300)).easing(Easing::SPRING))
    }

    /// Como [`Self::transition`], pero con la duración y curva indicadas.
    pub fn transition_with(&mut self, id: &str, target: f32, animation: Animation) -> f32 {
        let (value, running) = self.entities.animations.transition(id, target, animation, Instant::now());
        if running {
            self.entities.timers.request_animation_frame();
        }
        value
    }

    /// Entrada con fundido: 0 a 1 durante `millis` desde la primera vez que se pinta `id`.
    pub fn fade_in(&mut self, id: &str, millis: u64) -> f32 {
        self.animate(id, Animation::new(ms(millis)))
    }

    /// Segundos desde que `id` se usó por primera vez, para animaciones continuas (con [`crate::core::wave`]).
    /// Con `enabled = false` devuelve 0 y no pide más frames.
    pub fn phase(&mut self, id: &str, enabled: bool) -> f32 {
        if !enabled {
            return 0.0;
        }
        let day = Animation::new(Duration::from_secs_f32(PHASE_SECONDS)).easing(Easing::Linear);
        self.animate(id, day) * PHASE_SECONDS
    }

    // ---------- Segundo plano ----------

    /// Lanza un trabajo en el pool de hilos de la app; al terminar, ejecuta `done` en el hilo principal y redibuja.
    pub fn job<R: Send + 'static>(
        &self,
        work: impl FnOnce(&CancelToken) -> R + Send + 'static,
        done: impl FnOnce(R) + Send + 'static,
    ) -> JobHandle {
        let sender = self.entities.foreground();
        let pool = self.entities.background.clone();
        let root_id = self.root_id;
        let job = JobHandle::default();
        let token = job.cancel_token();
        pool.spawn(move || {
            let result = work(&token);
            if !token.is_cancelled() {
                sender.send(move |entities| {
                    done(result);
                    entities.mark_dirty(root_id);
                });
            }
        });
        job
    }

    /// Tarea reactiva en segundo plano («solo vale la última»): cada vez que `dep` cambia, cancela el trabajo
    /// anterior, lanza `work` en el pool de hilos y, al terminar, guarda el resultado y redibuja.
    ///
    /// Devuelve `Some(R)` con el último resultado listo (o `None` mientras termina la primera ejecución).
    pub fn task<D, R>(
        &mut self,
        key: &'static str,
        dep: D,
        work: impl FnOnce(D, &CancelToken) -> R + Send + 'static,
    ) -> Option<R>
    where
        D: PartialEq + Clone + Send + 'static,
        R: Clone + Send + 'static,
    {
        let handle = match self.widgets.task::<TaskSlot<D, R>>(key) {
            Some(handle) => handle,
            None => {
                let handle =
                    self.entities.insert(TaskSlot::<D, R> { dep: None, latest: Latest::default(), value: None });
                self.widgets.insert_task(key, handle);
                handle
            }
        };
        let root_id = self.root_id;
        self.entities
            .update(handle, |slot, cx| {
                if slot.dep.as_ref() != Some(&dep) {
                    slot.dep = Some(dep.clone());
                    let dep_for_work = dep;
                    slot.latest.run(
                        cx,
                        move |cancel| work(dep_for_work, cancel),
                        move |slot, result, cx| {
                            slot.value = Some(result);
                            cx.mark_dirty(root_id);
                        },
                    );
                }
                slot.value.clone()
            })
            .flatten()
    }
}