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.
//! Modal y notificaciones flotantes (el menú contextual está en `context_menu`).

use crate::core::{Rgba, px};
use crate::view::interactivity::ClickHandler;
use crate::view::prelude::*;
use crate::widgets::icons::{IconName, icon};

/// Fondo atenuado que bloquea la interfaz y centra `content` arriba.
pub fn modal(content: impl IntoElement, on_dismiss: Option<ClickHandler>) -> Div {
    div()
        .absolute()
        .inset_0()
        .flex_col()
        .items_center()
        .occlude()
        .bg(Rgba::new(0.0, 0.0, 0.0, 0.45))
        .when_some(on_dismiss, |backdrop, handler| backdrop.on_mouse_down(move |event, cx| handler(event, cx)))
        .child(div().mt(px(88.0)).child(content))
}

/// Aviso breve en la esquina inferior derecha; quien lo muestra decide cuándo quitarlo.
pub fn toast(message: impl Into<String>, is_error: bool) -> Div {
    toast_with(wrap_words(&message.into(), TOAST_LINE_CHARS), is_error)
}

/// Caracteres por línea de un aviso largo (cabe en los 420 px del cuadro).
const TOAST_LINE_CHARS: usize = 48;

/// Parte el texto en líneas de como mucho `width` caracteres, cortando en espacios; una palabra más larga
/// queda entera en su línea. Los saltos de línea que ya hubiera se respetan.
pub fn wrap_words(text: &str, width: usize) -> String {
    let mut lines: Vec<String> = Vec::new();
    for paragraph in text.split('\n') {
        let mut line = String::new();
        for word in paragraph.split_whitespace() {
            let needed = if line.is_empty() { 0 } else { 1 } + word.chars().count();
            if !line.is_empty() && line.chars().count() + needed > width {
                lines.push(std::mem::take(&mut line));
            }
            if !line.is_empty() {
                line.push(' ');
            }
            line.push_str(word);
        }
        lines.push(line);
    }
    lines.join("\n")
}

/// Aviso con contenido propio (por ejemplo, una etiqueta seleccionable).
pub fn toast_with(content: impl IntoElement, is_error: bool) -> Div {
    div().absolute().inset_0().flex_col().items_end().justify_end().p_6().child(
        div()
            .occlude()
            .flex_row()
            .items_center()
            .gap_2()
            .max_w(px(420.0))
            .px_3()
            .py_2()
            .rounded_lg()
            .border_1()
            .border_color(if is_error { Token::Danger } else { Token::Border })
            .bg(Token::ElevatedSurface)
            .shadow_lg()
            .text_sm()
            .whitespace_normal()
            .child(icon(if is_error { IconName::CircleAlert } else { IconName::Info }).text_color(if is_error {
                Token::Danger
            } else {
                Token::Accent
            }))
            .child(content),
    )
}

#[cfg(test)]
mod toast_tests {
    use super::wrap_words;

    #[test]
    fn el_texto_largo_se_parte_en_espacios() {
        let wrapped = wrap_words("ajustes de IA: configura el ID exacto del modelo de tu cuenta o servidor", 30);
        assert_eq!(wrapped, "ajustes de IA: configura el ID\nexacto del modelo de tu cuenta\no servidor");
        assert!(wrapped.lines().all(|line| line.chars().count() <= 30));
    }

    #[test]
    fn respeta_saltos_previos_y_palabras_largas() {
        assert_eq!(wrap_words("a\nb", 10), "a\nb");
        assert_eq!(wrap_words("https://ejemplo.invalid/ruta muy larga", 10), "https://ejemplo.invalid/ruta\nmuy larga");
        assert_eq!(wrap_words("corto", 48), "corto");
    }
}