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 {
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);
}
}