tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
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}