tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main widgets/toast/src/lib.rs Rust · 612 líneas · 23.3 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

1//! `llimphi-widget-toast` — notificaciones efímeras apiladas.
2//!
3//! Cuatro severidades (Info / Success / Warning / Error) con color
4//! semántico hardcoded — un Error debe leerse rojo aunque la app esté
5//! en tema "sunset". Cada toast lleva un icono de `llimphi-icons` y
6//! un texto corto.
7//!
8//! El widget es **render-only**: recibe una lista de [`Toast`]s ya
9//! filtrados por la app (los que aún no expiraron) y los apila en la
10//! esquina bottom-right. El ciclo de vida (push, auto-dismiss tras
11//! `duration`, dismiss manual al click) lo maneja la app desde su
12//! `update`/`spawn`.
13//!
14//! Patrón típico:
15//! 1. App tiene `Vec<Toast>` en el modelo + `next_id: u64`.
16//! 2. Para pushear: agregar Toast con `expires_at = Instant::now() + dur`
17//!    + `handle.spawn(move || { sleep(dur); Msg::ToastExpire(id) })`.
18//! 3. `view_overlay` filtra los no expirados y los pasa a `toast_stack_view`.
19
20#![forbid(unsafe_code)]
21
22
23// Sin `std`: este widget no usa nada del sistema —sólo `alloc` para
24// `String`/`Vec`—, y declararlo es lo que le permite cruzar a una jaula
25// WASM o al kernel. Antes enlazaba `std` por omisión (W4.6).
26#![no_std]
27
28extern crate alloc;
29
30use alloc::string::String;
31use alloc::vec::Vec;
32use alloc::vec;
33// El reloj del compositor: `web_time::Instant` bajo std, y SIN `now()` en el
34// perfil no_std — ahí el instante del fotograma entra por argumento, y un uso
35// equivocado es un error de compilación en vez de una animación congelada.
36use llimphi_compositor::Instant;
37
38use llimphi_compositor::llimphi_layout::taffy::{
39    prelude::{auto, length, percent, FlexDirection, Position, Size, Style},
40    AlignItems, JustifyContent, Rect,
41};
42use llimphi_compositor::peniko::Color;
43use llimphi_compositor::llimphi_text::Alignment;
44use llimphi_compositor::{Shadow, View};
45use llimphi_icons::{icon_view, Icon};
46use llimphi_theme::{elevation, motion, radius};
47
48/// Severidad del toast — define color e icono.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ToastKind {
51    Info,
52    Success,
53    Warning,
54    Error,
55}
56
57impl ToastKind {
58    /// Color de fondo (semántico, no dependiente del theme).
59    ///
60    /// Sigue siendo el color **plano** de la severidad: es el que usa quien
61    /// pinta un toast a mano y el que ancla el extremo oscuro del gradiente
62    /// ([`Self::degradado`]).
63    pub fn bg(self) -> Color {
64        match self {
65            ToastKind::Info => Color::from_rgba8(28, 56, 88, 245),
66            ToastKind::Success => Color::from_rgba8(28, 72, 44, 245),
67            ToastKind::Warning => Color::from_rgba8(88, 64, 20, 245),
68            ToastKind::Error => Color::from_rgba8(96, 32, 32, 245),
69        }
70    }
71
72    /// **Los dos extremos del gradiente del fondo**, en diagonal.
73    ///
74    /// El toast plano se perdía contra cualquier ventana oscura: un rectángulo
75    /// de un solo color a 245 de alfa se lee como parte del fondo, y una
76    /// notificación que hay que buscar no cumple su único trabajo. El extremo
77    /// claro es la severidad **subida** (no un gris: un azul más azul, un rojo
78    /// más rojo), así que el degradé refuerza el color en vez de lavarlo.
79    pub fn degradado(self) -> (Color, Color) {
80        let claro = match self {
81            ToastKind::Info => Color::from_rgba8(46, 92, 142, 250),
82            ToastKind::Success => Color::from_rgba8(42, 112, 70, 250),
83            ToastKind::Warning => Color::from_rgba8(136, 98, 30, 250),
84            ToastKind::Error => Color::from_rgba8(150, 48, 48, 250),
85        };
86        (claro, self.bg())
87    }
88
89    /// **El borde**: la severidad a media luz. Un contorno de 1 px es lo que le
90    /// da canto a la tarjeta sobre un fondo claro, donde la sombra no se ve.
91    pub fn borde(self) -> Color {
92        self.fg().with_alpha(0.45)
93    }
94
95    /// Color del trazo y del texto principal.
96    pub fn fg(self) -> Color {
97        match self {
98            ToastKind::Info => Color::from_rgba8(180, 220, 250, 255),
99            ToastKind::Success => Color::from_rgba8(180, 240, 200, 255),
100            ToastKind::Warning => Color::from_rgba8(250, 220, 160, 255),
101            ToastKind::Error => Color::from_rgba8(250, 200, 200, 255),
102        }
103    }
104
105    pub fn icon(self) -> Icon {
106        match self {
107            ToastKind::Info => Icon::Info,
108            ToastKind::Success => Icon::Check,
109            ToastKind::Warning => Icon::Warning,
110            ToastKind::Error => Icon::Error,
111        }
112    }
113}
114
115/// Un toast en cola. La app mantiene `Vec<Toast>` y descarta los
116/// expirados antes de pasarlos al render.
117#[derive(Debug, Clone)]
118pub struct Toast {
119    /// Id estable para que la app pueda correlacionar con su Msg de
120    /// dismiss (`Msg::ToastDismiss(u64)`).
121    pub id: u64,
122    pub kind: ToastKind,
123    /// **El título**, en su propio renglón y en negrita. Vacío = el toast es de
124    /// una sola línea, como fue siempre.
125    ///
126    /// Existe porque el protocolo freedesktop **ya trae dos campos** (`summary`
127    /// y `body`) y el daemon los venía aplastando en uno con un guion en el
128    /// medio: `"Descarga lista — archivo.zip"`. Eso pierde justo la jerarquía
129    /// que hace que un aviso se lea de reojo — qué pasó primero, el detalle
130    /// después.
131    pub titulo: String,
132    /// El cuerpo. Con [`Self::titulo`] vacío es la única línea (y va en el
133    /// tamaño y el peso del título): un toast sin título no se pinta como un
134    /// título faltante.
135    pub text: String,
136    /// Cuándo expira. El render no chequea esto — sólo apila lo que
137    /// recibe; la app filtra antes.
138    pub expires_at: Instant,
139    /// Acciones `(clave, etiqueta)` a pintar como botones. Vacío = sin
140    /// botones (un toast informativo normal). Las pinta
141    /// [`toast_stack_view_con_acciones`]; [`toast_stack_view`] las ignora.
142    pub actions: Vec<(String, String)>,
143}
144
145const TOAST_W: f32 = 340.0;
146/// Alto de un toast de **una línea** (sin título, o sin cuerpo).
147const TOAST_H: f32 = 46.0;
148/// Alto de un toast **con título y cuerpo**: dos renglones y aire.
149const TOAST_H_2: f32 = 64.0;
150const ICON_BOX: f32 = 26.0;
151const GAP: f32 = 8.0;
152const MARGIN: f32 = 16.0;
153/// Ancho del "rail" de severidad en el edge izquierdo. Era de 3 px —el sweet
154/// spot de un toast plano de una línea—; con la tarjeta más alta y el fondo en
155/// degradé, 3 px se leían como el canto de la caja y no como la marca de
156/// severidad que son.
157const RAIL_W: f32 = 4.0;
158
159/// El alto que ocupa este toast. Público porque quien lo apila necesita
160/// reservar el lugar exacto, y esa cuenta hecha a mano en el caller se
161/// desincroniza sin que nada falle: la pila queda corrida y el de más abajo se
162/// sale de la caja.
163pub fn alto_de(t: &Toast) -> f32 {
164    let base = if t.titulo.is_empty() || t.text.is_empty() {
165        TOAST_H
166    } else {
167        TOAST_H_2
168    };
169    // **Con dos acciones o más, los botones van en su propio renglón** y la
170    // tarjeta mide más. Ver [`single_toast_view`]: en línea no entran.
171    if t.actions.len() >= 2 {
172        base + FILA_ACCIONES_H
173    } else {
174        base
175    }
176}
177
178/// Alto del renglón de acciones cuando hay dos o más: el botón (26) más aire.
179const FILA_ACCIONES_H: f32 = 34.0;
180
181/// Apila los toasts en la esquina bottom-right del viewport. `on_click`
182/// se construye por toast vía `make_dismiss(id)`. Devuelve un `View`
183/// para colgar de `view_overlay`.
184pub fn toast_stack_view<Msg, F>(
185    toasts: &[Toast],
186    viewport: (f32, f32),
187    make_dismiss: F,
188) -> View<Msg>
189where
190    Msg: Clone + 'static,
191    F: Fn(u64) -> Msg,
192{
193    let children: Vec<View<Msg>> = toasts
194        .iter()
195        .map(|t| single_toast_view(t, make_dismiss(t.id), Vec::new()))
196        .collect();
197    armar_stack(children, toasts, viewport)
198}
199
200/// Igual que [`toast_stack_view`] pero pinta los botones de acción de cada
201/// toast (`Toast::actions`). `make_action(id, clave)` arma el `Msg` que la app
202/// recibe al clickear un botón. El click en el cuerpo (fuera de un botón) sigue
203/// disparando `make_dismiss`.
204pub fn toast_stack_view_con_acciones<Msg, FD, FA>(
205    toasts: &[Toast],
206    viewport: (f32, f32),
207    make_dismiss: FD,
208    make_action: FA,
209) -> View<Msg>
210where
211    Msg: Clone + 'static,
212    FD: Fn(u64) -> Msg,
213    FA: Fn(u64, &str) -> Msg,
214{
215    let children: Vec<View<Msg>> = toasts
216        .iter()
217        .map(|t| {
218            let botones: Vec<(String, Msg)> = t
219                .actions
220                .iter()
221                .map(|(clave, etiqueta)| (etiqueta.clone(), make_action(t.id, clave)))
222                .collect();
223            single_toast_view(t, make_dismiss(t.id), botones)
224        })
225        .collect();
226    armar_stack(children, toasts, viewport)
227}
228
229/// Apila las tarjetas ya construidas en la esquina bottom-right del viewport.
230fn armar_stack<Msg: Clone + 'static>(
231    children: Vec<View<Msg>>,
232    toasts: &[Toast],
233    viewport: (f32, f32),
234) -> View<Msg> {
235    let n = toasts.len() as f32;
236    // La suma de los altos REALES, no `n × TOAST_H`: desde que un toast con
237    // título mide distinto que uno sin él, multiplicar por una constante deja
238    // la pila corrida hacia abajo y el de más abajo fuera de la caja.
239    let stack_h: f32 =
240        toasts.iter().map(alto_de).sum::<f32>() + (n - 1.0).max(0.0) * GAP;
241    let stack_y = (viewport.1 - stack_h - MARGIN).max(MARGIN);
242    let stack_x = (viewport.0 - TOAST_W - MARGIN).max(MARGIN);
243
244    let stack = View::new(Style {
245        position: Position::Absolute,
246        inset: Rect {
247            left: length(stack_x),
248            top: length(stack_y),
249            right: auto(),
250            bottom: auto(),
251        },
252        size: Size {
253            width: length(TOAST_W),
254            height: length(stack_h.max(0.0)),
255        },
256        flex_direction: FlexDirection::Column,
257        gap: Size {
258            width: length(0.0_f32),
259            height: length(GAP),
260        },
261        ..Default::default()
262    })
263    .children(children);
264
265    // El envoltorio es un **overlay absoluto**, no un hermano en el flujo.
266    //
267    // El uso esperado es `children(vec![contenido, toast_stack_view(..)])`. Si
268    // este nodo queda `Position::Relative` (el default), taffy lo trata como
269    // un ítem flex más con base 100 %: con el contenido pidiendo también
270    // 100 %, los dos encogen a la mitad. El contenido se aplasta contra un
271    // costado y esta mitad no pinta nada — y como el marco se limpia en
272    // TRANSPARENT, se ve **negra**. Le costó una tarde a pluma-notebook, donde
273    // el síntoma era «al ejecutar una celda se pone negra media pantalla»:
274    // media pantalla, porque el toast aparecía justo ahí.
275    View::new(Style {
276        position: Position::Absolute,
277        inset: Rect {
278            left: length(0.0_f32),
279            top: length(0.0_f32),
280            right: length(0.0_f32),
281            bottom: length(0.0_f32),
282        },
283        size: Size {
284            width: percent(1.0_f32),
285            height: percent(1.0_f32),
286        },
287        ..Default::default()
288    })
289    .children(vec![stack])
290}
291
292/// Un botón de acción: pequeño, click emite su `Msg` (gana al click de dismiss
293/// del cuerpo porque el hit-test toma el nodo más profundo).
294fn boton_accion<Msg: Clone + 'static>(etiqueta: String, msg: Msg, fg: Color) -> View<Msg> {
295    View::new(Style {
296        size: Size { width: auto(), height: length(26.0_f32) },
297        align_items: Some(AlignItems::CENTER),
298        flex_shrink: 0.0,
299        padding: Rect {
300            left: length(10.0_f32),
301            right: length(10.0_f32),
302            top: length(0.0_f32),
303            bottom: length(0.0_f32),
304        },
305        ..Default::default()
306    })
307    .fill(Color::from_rgba8(255, 255, 255, 30))
308    .radius(6.0_f64)
309    .text_aligned(etiqueta, 11.0, fg, Alignment::Center)
310    .on_click(msg)
311}
312
313fn single_toast_view<Msg: Clone + 'static>(
314    toast: &Toast,
315    on_dismiss: Msg,
316    botones: Vec<(String, Msg)>,
317) -> View<Msg> {
318    use llimphi_compositor::peniko::kurbo::Point;
319    use llimphi_compositor::peniko::Gradient;
320
321    let fg = toast.kind.fg();
322    let icon = toast.kind.icon();
323    let alto = alto_de(toast);
324    let dos_lineas = alto > TOAST_H;
325
326    // Rail de severidad: stripe del color fg semántico (más brillante
327    // que el bg) en el edge izquierdo. Visible al pasar el ojo sin
328    // chocar con el icono — refuerza la severidad para usuarios que ya
329    // están mirando a otra parte de la UI.
330    let rail = View::new(Style {
331        size: Size {
332            width: length(RAIL_W),
333            height: percent(1.0_f32),
334        },
335        flex_shrink: 0.0,
336        ..Default::default()
337    })
338    .fill(fg);
339
340    let icon_cell = View::new(Style {
341        size: Size {
342            width: length(ICON_BOX),
343            height: length(ICON_BOX),
344        },
345        flex_shrink: 0.0,
346        ..Default::default()
347    })
348    .children(vec![icon_view(icon, fg, 1.8)]);
349
350    // **La columna de texto.** Con título y cuerpo son dos renglones con pesos
351    // distintos; sin título, el cuerpo ocupa el lugar del título (y su tamaño),
352    // que es lo que hace que un toast de una línea no se lea como uno al que le
353    // falta el encabezado.
354    let (primera, segunda) = if toast.titulo.is_empty() {
355        (toast.text.clone(), String::new())
356    } else {
357        (toast.titulo.clone(), toast.text.clone())
358    };
359    let mut renglones: Vec<View<Msg>> = vec![View::new(Style {
360        size: Size {
361            width: percent(1.0_f32),
362            height: length(if dos_lineas { 18.0_f32 } else { ICON_BOX }),
363        },
364        flex_shrink: 0.0,
365        align_items: Some(AlignItems::CENTER),
366        ..Default::default()
367    })
368    .text_aligned(primera, 13.0, fg, Alignment::Start)
369    .bold()
370    .no_wrap()];
371    if dos_lineas {
372        renglones.push(
373            View::new(Style {
374                size: Size {
375                    width: percent(1.0_f32),
376                    height: length(16.0_f32),
377                },
378                flex_shrink: 0.0,
379                align_items: Some(AlignItems::CENTER),
380                ..Default::default()
381            })
382            // El cuerpo va **más chico y a media luz**: es el detalle, y con el
383            // mismo peso que el título los dos renglones compiten y no se lee
384            // ninguno de los dos de reojo.
385            .text_aligned(segunda, 11.5, fg.with_alpha(0.78), Alignment::Start)
386            .no_wrap(),
387        );
388    }
389    // ⚠️ **`min_size.width = 0` no es cosmético: es lo que evita que el botón de
390    // acción se salga del toast.** El `min-size: auto` de flexbox vale el
391    // min-content del hijo, y un renglón `no_wrap` tiene por min-content el
392    // ancho del texto ENTERO. Con un título largo la columna no podía encoger,
393    // la fila se pasaba del ancho del toast y el que se iba afuera era el
394    // botón —que es `flex_shrink: 0`, y con razón: un botón encogido es un
395    // botón sin rótulo—. En metal se leía «Aproba» sobre un pedido de permiso
396    // (2026-09-21). Con el mínimo en cero, lo que se recorta es el texto,
397    // contra el `.clip(true)` de la tarjeta, y el botón queda entero: el texto
398    // cortado se sigue entendiendo, un botón cortado no se puede ni apretar.
399    let text = View::new(Style {
400        flex_direction: FlexDirection::Column,
401        size: Size {
402            width: auto(),
403            height: percent(1.0_f32),
404        },
405        min_size: Size {
406            width: length(0.0_f32),
407            height: auto(),
408        },
409        flex_grow: 1.0,
410        flex_shrink: 1.0,
411        justify_content: Some(JustifyContent::CENTER),
412        ..Default::default()
413    })
414    // **Y se recorta.** Dejar encoger el nodo no alcanza: el texto se pinta
415    // igual y le pasa por encima al botón de al lado. Con el clip, un título
416    // que no entra se corta — que se lee bien igual, y no tapa la acción.
417    .clip(true)
418    .children(renglones);
419
420    // Sombra E3 + entrada/salida animada (key estable = id del toast):
421    // el toast aparece con fade-in suave y, al expirar/dismiss, su
422    // subescena se reproduce con fade-out — sin necesidad de tween
423    // manual en la app.
424    let (alpha, blur, dy) = elevation::E3;
425    let shadow = Shadow {
426        color: Color::from_rgba8(0, 0, 0, alpha),
427        blur,
428        dx: 0.0,
429        dy,
430        spread: 0.0,
431    };
432
433    // **El fondo en degradé diagonal**, no un color plano: ver
434    // [`ToastKind::degradado`]. Se autorea en el cuadrado unidad y el runtime lo
435    // mapea al rect del nodo, así que no hace falta saber el tamaño acá.
436    let (claro, oscuro) = toast.kind.degradado();
437    let fondo = Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0))
438        .with_stops([claro, oscuro].as_slice());
439
440    View::new(Style {
441        flex_direction: FlexDirection::Row,
442        size: Size {
443            width: percent(1.0_f32),
444            height: length(alto),
445        },
446        align_items: Some(AlignItems::CENTER),
447        // El rail vive en el edge — sin padding-left propio para que
448        // pegue al borde; el padding del contenido arranca después.
449        padding: Rect {
450            left: length(0.0_f32),
451            right: length(12.0_f32),
452            top: length(0.0_f32),
453            bottom: length(0.0_f32),
454        },
455        gap: Size {
456            width: length(10.0_f32),
457            height: length(0.0_f32),
458        },
459        flex_shrink: 0.0,
460        ..Default::default()
461    })
462    .fill_gradient(fondo)
463    .border(1.0, toast.kind.borde())
464    .radius(radius::MD)
465    .shadow(shadow)
466    .animated_inout(toast.id, motion::NORMAL)
467    .clip(true)
468    // Alert: se anuncia al aparecer sin que el usuario navegue hasta él. El
469    // texto vive en un hijo, de ahí el label explícito.
470    .role(llimphi_compositor::Role::Alert)
471    .aria_label(etiqueta_accesible(toast))
472    .on_click(on_dismiss)
473    .children({
474        // **Un botón va en línea; dos o más, en su propio renglón.**
475        //
476        // Medido en metal el 2026-09-21 con el aviso de permiso, que trae
477        // «Aprobar» y «No»: en línea no entran en `TOAST_W`, y lo que se ve no
478        // es un botón chico sino **el botón encima del texto** — los dos
479        // pintados en el mismo lugar. Un toast que superpone su acción con lo
480        // que hay que leer para decidirla no se puede usar.
481        //
482        // Con uno solo se deja como estaba: ahí entra, y el renglón de más
483        // sería alto gastado en el caso común.
484        if botones.len() < 2 {
485            let mut hijos = vec![rail, icon_cell, text];
486            for (etiqueta, msg) in botones {
487                hijos.push(boton_accion(etiqueta, msg, fg));
488            }
489            hijos
490        } else {
491        let fila_botones = View::new(Style {
492            flex_direction: FlexDirection::Row,
493            size: Size { width: percent(1.0_f32), height: length(FILA_ACCIONES_H) },
494            align_items: Some(AlignItems::CENTER),
495            // A la derecha, que es donde los busca la mano — y donde los pone
496            // cualquier otro escritorio.
497            justify_content: Some(JustifyContent::END),
498            gap: Size { width: length(8.0_f32), height: length(0.0_f32) },
499            flex_shrink: 0.0,
500            ..Default::default()
501        })
502        .children(
503            botones
504                .into_iter()
505                .map(|(etiqueta, msg)| boton_accion(etiqueta, msg, fg))
506                .collect(),
507        );
508        // El texto y los botones, uno arriba del otro; el rail y el icono
509        // siguen a la izquierda de los dos, que es lo que mantiene la tarjeta
510        // reconociblemente la misma.
511        let columna = View::new(Style {
512            flex_direction: FlexDirection::Column,
513            size: Size { width: auto(), height: percent(1.0_f32) },
514            min_size: Size { width: length(0.0_f32), height: auto() },
515            flex_grow: 1.0,
516            flex_shrink: 1.0,
517            justify_content: Some(JustifyContent::CENTER),
518            ..Default::default()
519        })
520        .children(vec![text, fila_botones]);
521        vec![rail, icon_cell, columna]
522        }
523    })
524}
525
526/// Lo que lee un lector de pantalla: título y cuerpo en una sola frase. Con el
527/// texto partido en dos nodos, el `aria_label` del contenedor es el único lugar
528/// donde el aviso vuelve a estar entero.
529fn etiqueta_accesible(t: &Toast) -> String {
530    if t.titulo.is_empty() {
531        return t.text.clone();
532    }
533    if t.text.is_empty() {
534        return t.titulo.clone();
535    }
536    let mut s = t.titulo.clone();
537    s.push_str(". ");
538    s.push_str(&t.text);
539    s
540}
541
542/// Helper de construcción para uso inmediato:
543/// `Toast::info(1, "guardado", Duration::from_secs(3))`.
544impl Toast {
545    // GATEADOS A `std` los constructores que arrancan reloj: `Instant::now()` no
546    // existe en el perfil sin sistema. La VISTA sí cruza — `is_alive(now)` ya
547    // recibe el instante por parámetro, que es la disciplina correcta; quien
548    // corra en una jaula construye el `Toast` con un `expires_at` derivado del
549    // reloj de fotograma cuando lo tenga.
550
551    #[cfg(feature = "std")]
552    pub fn info(id: u64, text: impl Into<String>, dur: core::time::Duration) -> Self {
553        Self { id, titulo: String::new(), kind: ToastKind::Info, text: text.into(), expires_at: Instant::now() + dur, actions: Vec::new() }
554    }
555    #[cfg(feature = "std")]
556    pub fn success(id: u64, text: impl Into<String>, dur: core::time::Duration) -> Self {
557        Self { id, titulo: String::new(), kind: ToastKind::Success, text: text.into(), expires_at: Instant::now() + dur, actions: Vec::new() }
558    }
559    #[cfg(feature = "std")]
560    pub fn warning(id: u64, text: impl Into<String>, dur: core::time::Duration) -> Self {
561        Self { id, titulo: String::new(), kind: ToastKind::Warning, text: text.into(), expires_at: Instant::now() + dur, actions: Vec::new() }
562    }
563    #[cfg(feature = "std")]
564    pub fn error(id: u64, text: impl Into<String>, dur: core::time::Duration) -> Self {
565        Self { id, titulo: String::new(), kind: ToastKind::Error, text: text.into(), expires_at: Instant::now() + dur, actions: Vec::new() }
566    }
567
568    /// Adjunta acciones `(clave, etiqueta)` que se pintarán como botones.
569    pub fn con_acciones(mut self, actions: Vec<(String, String)>) -> Self {
570        self.actions = actions;
571        self
572    }
573
574    /// Le pone **título**: el texto que ya tenía pasa a ser el cuerpo, en un
575    /// segundo renglón más chico. Ver [`Toast::titulo`].
576    pub fn con_titulo(mut self, titulo: impl Into<String>) -> Self {
577        self.titulo = titulo.into();
578        self
579    }
580
581    pub fn is_alive(&self, now: Instant) -> bool {
582        now < self.expires_at
583    }
584}
585
586#[cfg(all(test, feature = "std"))]
587mod tests {
588    use super::*;
589
590    /// El stack tiene que ser un **overlay**: si vuelve al flujo, el caller
591    /// que lo pone junto a su contenido (el patrón documentado arriba) ve el
592    /// contenido encogido a la mitad y la otra mitad en negro. Es un contrato,
593    /// no una preferencia de estilo — por eso se afirma.
594    #[test]
595    fn el_stack_es_overlay_absoluto_y_no_ocupa_lugar_en_el_flujo() {
596        let t = Toast::info(1, "hola", core::time::Duration::from_secs(1));
597        let v: View<u8> = toast_stack_view(&[t], (800.0, 600.0), |_| 0u8);
598        assert_eq!(
599            v.style.position,
600            Position::Absolute,
601            "el envoltorio del stack robaría espacio flex al contenido"
602        );
603    }
604
605    /// Sin toasts vivos la app no llama al widget, pero si lo hiciera no debe
606    /// romperse ni tapar nada.
607    #[test]
608    fn stack_vacio_sigue_siendo_overlay() {
609        let v: View<u8> = toast_stack_view(&[], (800.0, 600.0), |_| 0u8);
610        assert_eq!(v.style.position, Position::Absolute);
611    }
612}