aura-gui 0.2.2

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.
//! Utilidades de Tailwind de punta a punta (layout → pintado), sin GPU.

use aura_ui::gpu::{TextAlign, TextStyle, TextSystem, TextTraits, TextTransform};
use aura_ui::view::palette::{blue, slate};
use aura_ui::view::prelude::*;

struct Page(fn() -> AnyElement);

impl View for Page {
    fn view(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
        (self.0)()
    }
}

/// Quads pintados por `build` en una ventana de 400×300.
fn painted(build: fn() -> AnyElement) -> Vec<aura_ui::gpu::Quad> {
    let mut window = Window::new(Theme::dark(), Size::new(px(400.0), px(300.0)), 1.0);
    window.set_root(Page(build));
    window.draw(None);
    window.scene().quads.to_vec()
}

fn cell(color: u32) -> Div {
    div().bg(Rgba::hex(color)).h_10()
}

#[test]
fn la_cuadricula_reparte_columnas_iguales_y_respeta_el_hueco() {
    let quads = painted(|| {
        div()
            .w(px(320.0))
            .grid_cols_3()
            .gap_x_2()
            .child(cell(1))
            .child(cell(2))
            .child(cell(3))
            .child(cell(4).col_span_2())
            .into_element()
    });
    let near = |value: f32, expected: f32| assert!((value - expected).abs() <= 1.0, "{value} ≈ {expected}");
    let track = (320.0 - 2.0 * 8.0) / 3.0;
    near(quads[0].bounds[2], track);
    near(quads[1].bounds[0], track + 8.0);
    near(quads[2].bounds[0], 2.0 * (track + 8.0));
    assert_eq!(quads[3].bounds[1], 40.0, "la cuarta celda baja a la segunda fila");
    near(quads[3].bounds[2], 2.0 * track + 8.0);
}

#[test]
fn mx_auto_centra_y_flex_row_reverse_invierte() {
    let quads = painted(|| div().w(px(400.0)).child(cell(1).w(px(100.0)).mx_auto()).into_element());
    assert_eq!(quads[0].bounds[0], 150.0);

    let quads =
        painted(|| div().w(px(300.0)).flex_row_reverse().child(cell(1).w_20()).child(cell(2).w_20()).into_element());
    assert_eq!((quads[0].bounds[0], quads[1].bounds[0]), (220.0, 140.0), "el primero queda a la derecha");
}

#[test]
fn bordes_y_radios_por_lado_y_esquina() {
    let quads = painted(|| {
        div().size_20().bg(slate(800)).border_t_4().border_b().rounded_t_lg().rounded_br_full().into_element()
    });
    assert_eq!(quads[0].border_widths, [4.0, 0.0, 1.0, 0.0], "arriba, derecha, abajo, izquierda");
    assert_eq!(quads[0].corner_radii, [8.0, 8.0, 40.0, 0.0], "tl, tr, br, bl; `full` se limita a la mitad");
}

#[test]
fn el_anillo_rodea_la_caja_sin_ocupar_espacio() {
    let quads = painted(|| div().p_4().child(div().size_10().bg(blue(500)).ring_2().rounded_md()).into_element());
    let (ring, body) = (&quads[0], &quads[1]);
    assert_eq!(body.bounds, [16.0, 16.0, 40.0, 40.0]);
    assert_eq!(ring.bounds, [14.0, 14.0, 44.0, 44.0]);
    assert_eq!(ring.border_widths, [2.0; 4]);
    assert_eq!(ring.corner_radii, [8.0; 4], "el radio crece con el anillo");
}

#[test]
fn degradado_con_direccion_y_opacidad_en_pasos() {
    let quads = painted(|| div().size_10().bg_gradient_to_r(blue(500), blue(900)).opacity_50().into_element());
    assert_eq!(quads[0].gradient[0], 90.0_f32.to_radians().sin(), "hacia la derecha = 90°");
    assert_eq!(quads[0].background.a, 0.5);
}

fn shape(traits: TextTraits, width: Option<f32>) -> (f32, f32) {
    let mut system = TextSystem::new();
    let style =
        TextStyle { font_size: 14.0, line_height: 20.0, monospace: false, family: None, max_width: width, traits };
    let shaped = system.shape("una línea larga de texto que no cabe en cien píxeles", &style);
    (shaped.size.width.0, shaped.size.height.0)
}

#[test]
fn truncate_deja_una_linea_y_line_clamp_las_que_se_pidan() {
    let free = shape(TextTraits::default(), Some(100.0));
    assert!(free.1 > 40.0, "sin recorte se parte en varias líneas: {free:?}");
    let one = shape(TextTraits { lines: 1, ..Default::default() }, Some(100.0));
    assert_eq!(one.1, 20.0);
    assert!(one.0 <= 100.5, "cabe en el ancho: {one:?}");
    let two = shape(TextTraits { lines: 2, ..Default::default() }, Some(100.0));
    assert_eq!(two.1, 40.0);
}

#[test]
fn tracking_ensancha_y_mayusculas_cambian_el_texto() {
    let normal = shape(TextTraits::default(), None);
    let wide = shape(TextTraits { tracking: 0.1, ..Default::default() }, None);
    assert!(wide.0 > normal.0, "{wide:?} > {normal:?}");
    let upper = shape(TextTraits { transform: Some(TextTransform::Upper), ..Default::default() }, None);
    assert_ne!(upper.0, normal.0);
}

#[test]
fn la_alineacion_mueve_los_glifos_dentro_del_ancho() {
    let first_x = |align| {
        let mut system = TextSystem::new();
        let style = TextStyle {
            font_size: 14.0,
            line_height: 20.0,
            monospace: true,
            family: None,
            max_width: Some(300.0),
            traits: TextTraits { align, ..Default::default() },
        };
        system.shape("hola", &style).caret_x(0)
    };
    assert_eq!(first_x(None), 0.0);
    assert!(first_x(Some(TextAlign::Center)) > 100.0);
    assert!(first_x(Some(TextAlign::Right)) > first_x(Some(TextAlign::Center)));
}

fn responsive() -> AnyElement {
    div()
        .w(px(700.0))
        .flex_col()
        .sm(|s| s.flex_row())
        .md(|s| s.flex_col().gap_x_4())
        .dark(|s| s.bg(slate(900)))
        .light(|s| s.bg(slate(50)))
        .child(cell(1).w_10())
        .child(cell(2).w_10())
        .into_element()
}

#[test]
fn los_puntos_de_corte_miran_el_ancho_de_la_ventana() {
    let at = |width: f32| {
        let mut window = Window::new(Theme::dark(), Size::new(px(width), px(300.0)), 1.0);
        window.set_root(Page(responsive));
        window.draw(None);
        window.scene().quads.to_vec()
    };
    // 700 px: pasa `sm` (640) pero no `md` (768) → en fila.
    let row = at(700.0);
    assert!(row[2].bounds[0] > row[1].bounds[0], "los hijos van uno al lado del otro");
    // 900 px: `md` pisa a `sm` → en columna.
    let column = at(900.0);
    assert_eq!(column[1].bounds[0], column[2].bounds[0]);
    assert!(column[2].bounds[1] > column[1].bounds[1]);
    // Y `dark` solo si el tema lo es: el contenedor pinta su fondo.
    assert_eq!(row[0].background, slate(900));
}

/// El ejemplo del README tiene que compilar y pintar algo.
#[test]
fn el_ejemplo_del_readme_funciona() {
    let painted = painted(|| {
        div()
            .flex_col()
            .gap_4()
            .p_6()
            .rounded_xl()
            .border()
            .border_color(palette::slate(200))
            .md(|s| s.flex_row().items_center())
            .dark(|s| s.bg(palette::slate(900)).border_color(palette::slate(700)))
            .child(text("Título").text_2xl().font_semibold().tracking_tight().truncate())
            .child(div().w_1_3().h_10().rounded_lg().bg_gradient_to_r(palette::blue(500), palette::indigo(600)))
            .into_element()
    });
    assert!(painted.len() >= 2);
}

struct Clicks(Vec<&'static str>);

impl View for Clicks {
    fn view(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
        let on = |name: &'static str| {
            cx.listener(move |this: &mut Clicks, _: &ClickEvent, cx| {
                this.0.push(name);
                cx.notify();
            })
        };
        div()
            .relative()
            .size_full()
            // `abajo` va primero en el árbol pero con z-10 se pinta y recibe el ratón después.
            .child(
                div()
                    .id("abajo")
                    .absolute()
                    .top_0()
                    .left_0()
                    .size_20()
                    .bg(Rgba::hex(0xff0000))
                    .z_10()
                    .on_click(on("z-10")),
            )
            .child(
                div().id("arriba").absolute().top_0().left_0().size_20().bg(Rgba::hex(0x0000ff)).on_click(on("normal")),
            )
            .into_element()
    }
}

#[test]
fn z_index_ordena_el_pintado_y_el_raton() {
    use aura_ui::core::{InputEvent, MouseButton};
    let mut window = Window::new(Theme::dark(), Size::new(px(400.0), px(300.0)), 1.0);
    let handle = window.set_root(Clicks(Vec::new()));
    window.draw(None);
    let colors: Vec<_> = window.scene().quads.iter().map(|quad| quad.background.b > 0.5).collect();
    assert_eq!(colors, [true, false], "el azul (sin z) primero; el rojo (z-10) encima");
    let position = Point::new(px(10.0), px(10.0));
    window.handle_event(&InputEvent::MouseDown { position, button: MouseButton::Left });
    window.handle_event(&InputEvent::MouseUp { position, button: MouseButton::Left });
    assert_eq!(window.entities.read(handle).map(|view| view.0.clone()), Some(vec!["z-10"]));
}

fn window_with(build: fn() -> AnyElement) -> Window {
    let mut window = Window::new(Theme::dark(), Size::new(px(100.0), px(100.0)), 1.0);
    window.set_root(Page(build));
    window.draw(None);
    window
}

#[test]
fn las_transformaciones_viajan_con_su_lote_y_la_identidad_no_cuesta() {
    let plain = window_with(|| div().size_10().bg(blue(500)).into_element());
    assert!(plain.scene().transforms.is_empty(), "sin transformaciones no se guarda ninguna");
    assert!(plain.scene().batches.iter().all(|batch| batch.transform == 0));

    let spun = window_with(|| div().size_10().bg(blue(500)).rotate_45().scale_150().into_element());
    let scene = spun.scene();
    let batch = scene.batches.iter().find(|batch| batch.transform != 0).expect("un lote transformado");
    let matrix = scene.transforms[batch.transform as usize];
    assert!((matrix.scale_factor() - 1.5).abs() < 1e-4, "gira y escala 1,5×");
    assert!(!matrix.is_axis_aligned());
    // El centro del elemento (20, 20) no se mueve.
    let (x, y) = matrix.apply((20.0, 20.0));
    assert!((x - 20.0).abs() < 1e-3 && (y - 20.0).abs() < 1e-3);
}

#[test]
fn trasladar_y_escalar_se_ven_tambien_por_cpu() {
    let window = window_with(|| div().size_10().bg(Rgba::hex(0xff0000)).scale(2.0).translate_x_5().into_element());
    let frame = aura_ui::gpu::cpu::CpuRenderer::new(None, 100, 100).capture(window.scene(), 1.0, Rgba::hex(0xffffff));
    let red = |x: usize, y: usize| frame.rgba[(y * 100 + x) * 4..][..3] == [255, 0, 0];
    // 40×40 en (0,0) → ×2 desde el centro (20,20) → 80×80 en (-20,-20), y 20 px (5 × 4) a la derecha:
    // x de 0 a 80 e y de -20 a 60.
    assert!(red(70, 50) && red(2, 2), "ocupa x de 0 a 80 e y hasta 60");
    assert!(!red(90, 50) && !red(40, 70), "y no más allá");
}

fn glass() -> AnyElement {
    // Mitad negra, mitad blanca; un panel de cristal cruza la frontera (x = 50).
    div()
        .size_full()
        .flex_row()
        .child(div().w_1_2().h_full().bg(Rgba::hex(0x000000)))
        .child(div().w_1_2().h_full().bg(Rgba::hex(0xffffff)))
        .child(div().absolute().left_10().top_10().size_10().rounded_lg().backdrop_blur_md())
        .into_element()
}

#[test]
fn el_desenfoque_de_fondo_difumina_la_frontera_solo_dentro_del_panel() {
    let window = window_with(glass);
    let scene = window.scene();
    assert_eq!(scene.blurs.len(), 1);
    assert_eq!(scene.blurs[0].sigma, 12.0);
    assert_eq!(scene.blurs[0].bounds, [40.0, 40.0, 40.0, 40.0]);
    let frame = aura_ui::gpu::cpu::CpuRenderer::new(None, 100, 100).capture(scene, 1.0, Rgba::hex(0x000000));
    let gray = |x: usize, y: usize| frame.rgba[(y * 100 + x) * 4] as i32;
    assert!(gray(10, 10) < 5 && gray(90, 10) > 250, "fuera del panel la frontera sigue seca");
    assert!(
        gray(49, 60) > 40 && gray(49, 60) < 215 && gray(51, 60) > 40,
        "dentro del panel es un degradado: {} {}",
        gray(49, 60),
        gray(51, 60)
    );
    assert!(gray(49, 50) < gray(51, 50), "pero conserva el sentido: más claro a la derecha");
}

#[test]
fn blur_va_despues_de_los_hijos_y_backdrop_blur_antes_del_fondo() {
    let window = window_with(|| {
        div().size_10().bg(Rgba::hex(0x00ff00)).blur_sm().child(div().size_4().bg(Rgba::hex(0xff0000))).into_element()
    });
    let kinds: Vec<_> = window.scene().batches.iter().map(|batch| batch.kind).collect();
    use aura_ui::gpu::scene::PrimitiveKind::{Blur, Quad};
    assert_eq!(kinds, [Quad, Blur], "fondo e hijo (un solo lote de quads) y luego el desenfoque");
    let window = window_with(|| div().size_10().bg(Rgba::hex(0x00ff00)).backdrop_blur_sm().into_element());
    let kinds: Vec<_> = window.scene().batches.iter().map(|batch| batch.kind).collect();
    assert_eq!(kinds, [Blur, Quad], "el desenfoque va antes del fondo propio");
}