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.
//! Adaptador interno que convierte una closure `|ui| ...` en una vista del motor.
//!
//! El usuario no ve este tipo: `run_app` lo crea, guarda el estado por clave y vuelve a llamar a la closure en
//! cada frame. La closure no necesita `impl` ni nombrar el tipo de la vista.

use crate::core::platform::Store;
use crate::view::element::IntoElement;
use crate::view::render::{Component, ViewContext};
use crate::view::state::StateStore;
use crate::view::theme::Theme;
use crate::view::ui::Ui;
use crate::view::widget_store::WidgetStore;
use std::cell::RefCell;
use std::marker::PhantomData;
use std::rc::Rc;

fn sanitize_slug(title: &str) -> String {
    let mut slug = String::new();
    for ch in title.chars() {
        if ch.is_ascii_alphanumeric() {
            slug.push(ch.to_ascii_lowercase());
        } else if (ch == ' ' || ch == '-' || ch == '_') && !slug.ends_with('-') && !slug.is_empty() {
            slug.push('-');
        }
    }
    let slug = slug.trim_matches('-');
    if slug.is_empty() { "aura-app".into() } else { slug.to_owned() }
}

pub(crate) struct FnApp<F, R> {
    build: F,
    state: StateStore,
    widgets: WidgetStore,
    theme_request: Rc<RefCell<Option<Theme>>>,
    marker: PhantomData<R>,
}

impl<F, R> FnApp<F, R> {
    #[cfg(test)]
    pub(crate) fn new(build: F) -> Self {
        Self::with_store(None, Rc::default(), build)
    }

    pub(crate) fn for_title(title: &str, theme_request: Rc<RefCell<Option<Theme>>>, build: F) -> Self {
        let store = Store::new(&sanitize_slug(title), "prefs.json");
        Self::with_store(store, theme_request, build)
    }

    pub(crate) fn with_store(store: Option<Store>, theme_request: Rc<RefCell<Option<Theme>>>, build: F) -> Self {
        Self {
            build,
            state: StateStore::with_store(store),
            widgets: WidgetStore::default(),
            theme_request,
            marker: PhantomData,
        }
    }
}

impl<F, R> Component for FnApp<F, R>
where
    F: FnMut(&mut Ui) -> R + 'static,
    R: IntoElement + 'static,
{
    fn view(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
        let theme = cx.theme;
        let viewport = cx.viewport;
        let root_id = cx.handle().id;
        let entities = cx.entities_mut();
        let mut ui =
            Ui::new(theme, viewport, entities, &mut self.state, &mut self.widgets, root_id, &self.theme_request);
        (self.build)(&mut ui).into_element()
    }
}

impl crate::view::window::Window {
    /// Monta una raíz de closure conservando la inicialización avanzada de ventana (atajos, entidades y eventos).
    /// `ui.child(handle)` permite integrar componentes existentes sin recrearlos en cada frame.
    pub fn set_ui<F, R>(&mut self, title: &str, build: F)
    where
        F: FnMut(&mut Ui) -> R + 'static,
        R: IntoElement + 'static,
    {
        self.set_root(FnApp::for_title(title, self.theme_request.clone(), build));
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::view::prelude::*;
    use crate::view::test_context::TestWindow;
    use std::cell::Cell;
    use std::rc::Rc;

    #[test]
    fn una_closure_lee_el_estado_y_se_redibuja_al_pulsar() {
        let seen = Rc::new(Cell::new(0u32));
        let capture = seen.clone();
        let app = FnApp::new(move |ui: &mut Ui| {
            let count = ui.state("count", 0u32);
            capture.set(count.get());
            ui.page().center().child(ui.button("Sumar").on_click(count.update(|n| *n += 1)))
        });

        let (mut test, _) = TestWindow::new(|_| app);
        assert_eq!(seen.get(), 0);

        let [x, y, width, height] = test.window.scene().quads.last().expect("el botón pinta").bounds;
        test.simulate_click(x + width / 2.0, y + height / 2.0);
        assert_eq!(seen.get(), 1, "el handler mutó el estado y pidió otro frame");
    }

    #[test]
    fn la_lista_solo_construye_las_filas_visibles() {
        let built = Rc::new(Cell::new(0usize));
        let counter = built.clone();
        let app = FnApp::new(move |ui: &mut Ui| {
            let counter = counter.clone();
            ui.page().child(ui.list("lista", 10_000, px(20.0), move |ui, index| {
                counter.set(counter.get() + 1);
                ui.text(format!("fila {index}"))
            }))
        });

        let (_test, _) = TestWindow::new(|_| app);
        let built = built.get();
        assert!(built > 0, "la lista pinta al menos una fila");
        assert!(built < 100, "solo construye las visibles, no las 10.000: {built}");
    }

    #[test]
    fn un_componente_conserva_su_estado_entre_frames() {
        let seen = Rc::new(Cell::new(0u32));
        let capture = seen.clone();
        let app = FnApp::new(move |ui: &mut Ui| {
            let capture = capture.clone();
            ui.component(
                "panel",
                || 0u32,
                move |ui, value, handle| {
                    capture.set(*value);
                    ui.page().center().child(ui.button("Sumar").on_click(handle.update(|n| *n += 1)))
                },
            )
        });

        let (mut test, _) = TestWindow::new(|_| app);
        assert_eq!(seen.get(), 0);
        let [x, y, width, height] = test.window.scene().quads.last().expect("el botón pinta").bounds;
        test.simulate_click(x + width / 2.0, y + height / 2.0);
        assert_eq!(seen.get(), 1, "el componente conserva su valor y se redibuja");
    }

    #[test]
    fn text_field_submit_limpia_y_envia_con_enter() {
        let submitted = Rc::new(RefCell::new(Vec::<String>::new()));
        let capture = submitted.clone();
        let app = FnApp::new(move |ui: &mut Ui| {
            let texto = ui.state("texto", String::new());
            let capture = capture.clone();
            ui.page().child(ui.text_field_submit("Nueva", &texto, move |val| capture.borrow_mut().push(val)))
        });

        let (mut test, _) = TestWindow::new(|_| app);
        let [x, y, width, height] = test.window.scene().quads.last().expect("el input pinta").bounds;
        test.simulate_click(x + width / 2.0, y + height / 2.0);
        test.simulate_keystrokes("h o l a enter");
        assert_eq!(*submitted.borrow(), vec!["hola".to_owned()]);
    }

    #[test]
    fn persisted_carga_y_guarda_en_el_store() {
        let path = std::env::temp_dir().join(format!("aura-persisted-{}.json", std::process::id()));
        let _ = std::fs::remove_file(&path);
        let store = Store::at(&path);
        store.save(&serde_json::json!({ "clics": 7 })).unwrap();

        let seen = Rc::new(Cell::new(0u32));
        let capture = seen.clone();
        let app = FnApp::with_store(Some(store.clone()), Rc::default(), move |ui: &mut Ui| {
            let clics = ui.persisted("clics", 0u32);
            capture.set(clics.get());
            ui.page().center().child(ui.button("Sumar").on_click(clics.update(|n| *n += 1)))
        });

        let (mut test, _) = TestWindow::new(|_| app);
        assert_eq!(seen.get(), 7, "cargó el valor inicial del archivo");
        let [x, y, width, height] = test.window.scene().quads.last().expect("el botón pinta").bounds;
        test.simulate_click(x + width / 2.0, y + height / 2.0);
        assert_eq!(seen.get(), 8);
        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
        while store.load().get("clics").and_then(|v| v.as_u64()) != Some(8) {
            assert!(std::time::Instant::now() < deadline, "no se guardó en disco");
            std::thread::sleep(std::time::Duration::from_millis(5));
        }
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn task_reactivo_calcula_en_segundo_plano_y_redibuja() {
        let seen = Rc::new(Cell::new(0u32));
        let capture = seen.clone();
        let app = FnApp::new(move |ui: &mut Ui| {
            let base = ui.state("base", 21u32);
            let doble = ui.task("doble", base.get(), |n, _| n * 2).unwrap_or(0);
            capture.set(doble);
            ui.page().child(ui.text(format!("{doble}")))
        });

        let (mut test, _) = TestWindow::new(|_| app);
        test.run_until_parked();
        assert_eq!(seen.get(), 42);
    }
}