1use std::time::Duration;
14
15use super::Spinner;
16use super::cells;
17use super::close_mark;
18use crate::animation::AnimationName;
19use crate::color::Rgb;
20use crate::geometry::{Rect, clamp_u16};
21use crate::motion::{Easing, steps};
22use crate::style::CellStyle;
23use crate::text;
24use crate::widget::{Key, PaintCx, Widget, WidgetId};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum ToastKind {
29 Success,
31 Warning,
33 Danger,
35 #[default]
37 Info,
38}
39
40impl ToastKind {
41 pub const ALL: [Self; 4] = [Self::Success, Self::Warning, Self::Danger, Self::Info];
43
44 #[must_use]
46 pub fn name(self) -> &'static str {
47 match self {
48 Self::Success => "success",
49 Self::Warning => "warning",
50 Self::Danger => "danger",
51 Self::Info => "info",
52 }
53 }
54
55 fn icon(self) -> &'static str {
56 match self {
57 Self::Danger => "error",
58 other => other.name(),
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
65pub enum Corner {
66 TopRight,
68 #[default]
70 BottomRight,
71 BottomLeft,
73 TopLeft,
75}
76
77impl Corner {
78 pub const ALL: [Self; 4] = [Self::TopRight, Self::BottomRight, Self::BottomLeft, Self::TopLeft];
80
81 #[must_use]
83 pub fn name(self) -> &'static str {
84 match self {
85 Self::TopRight => "top-right",
86 Self::BottomRight => "bottom-right",
87 Self::BottomLeft => "bottom-left",
88 Self::TopLeft => "top-left",
89 }
90 }
91
92 fn right(self) -> bool {
93 matches!(self, Self::TopRight | Self::BottomRight)
94 }
95
96 fn bottom(self) -> bool {
97 matches!(self, Self::BottomRight | Self::BottomLeft)
98 }
99}
100
101const DEFAULT_DURATION: Duration = Duration::from_secs(5);
103
104const ACTION_DURATION: Duration = Duration::from_secs(8);
106
107const MAX_WIDTH: u16 = 56;
109
110fn text_indent(icon_width: u16) -> u16 {
113 icon_width.saturating_add(3)
114}
115
116pub struct Toast<Msg> {
127 kind: ToastKind,
128 title: String,
129 body: Option<String>,
130 action: Option<(String, Msg)>,
131 duration: Option<Duration>,
132 key: Option<String>,
133 icon_motion: Option<AnimationName>,
134 on_press: Option<Box<dyn Fn() -> Msg>>,
136}
137
138impl<Msg> Toast<Msg> {
139 #[must_use]
141 pub fn new(kind: ToastKind, title: impl Into<String>) -> Self {
142 Self {
143 kind,
144 title: title.into(),
145 body: None,
146 action: None,
147 duration: None,
148 key: None,
149 icon_motion: None,
150 on_press: None,
151 }
152 }
153
154 #[must_use]
156 pub fn success(title: impl Into<String>) -> Self {
157 Self::new(ToastKind::Success, title)
158 }
159
160 #[must_use]
162 pub fn warning(title: impl Into<String>) -> Self {
163 Self::new(ToastKind::Warning, title)
164 }
165
166 #[must_use]
168 pub fn danger(title: impl Into<String>) -> Self {
169 Self::new(ToastKind::Danger, title)
170 }
171
172 #[must_use]
174 pub fn info(title: impl Into<String>) -> Self {
175 Self::new(ToastKind::Info, title)
176 }
177
178 #[must_use]
180 pub fn body(mut self, body: impl Into<String>) -> Self {
181 self.body = Some(body.into());
182 self
183 }
184
185 #[must_use]
187 pub fn action(mut self, label: impl Into<String>, message: Msg) -> Self {
188 self.action = Some((label.into(), message));
189 self
190 }
191
192 #[must_use]
195 pub fn duration(mut self, duration: Duration) -> Self {
196 self.duration = Some(duration);
197 self
198 }
199
200 #[must_use]
203 pub fn key(mut self, key: impl Into<String>) -> Self {
204 self.key = Some(key.into());
205 self
206 }
207
208 #[must_use]
224 pub fn icon_motion(mut self, animation: impl Into<AnimationName>) -> Self {
225 self.icon_motion = Some(animation.into());
226 self
227 }
228}
229
230impl<Msg: 'static> Toast<Msg> {
231 pub(crate) fn map<B: 'static>(self, map: std::sync::Arc<dyn Fn(Msg) -> B + Send + Sync>) -> Toast<B> {
233 let action = self.action.map(|(label, message)| (label, map(message)));
234 let on_press = self.on_press.map(|press| Box::new(move || map(press())) as Box<dyn Fn() -> B>);
235 Toast {
236 kind: self.kind,
237 title: self.title,
238 body: self.body,
239 action,
240 duration: self.duration,
241 key: self.key,
242 icon_motion: self.icon_motion,
243 on_press,
244 }
245 }
246}
247
248impl<Msg: Clone + 'static> Toast<Msg> {
249 #[must_use]
266 pub fn on_press(mut self, message: Msg) -> Self {
267 self.on_press = Some(Box::new(move || message.clone()));
268 self
269 }
270}
271
272struct Entry<Msg> {
273 toast: Toast<Msg>,
274 id: WidgetId,
275 remaining: Duration,
276 ticked: Option<Duration>,
278 shown_at: Option<Duration>,
279 leaving: bool,
280 left_at: Option<Duration>,
281 waiting: bool,
284 rect: Rect,
285 action_rect: Rect,
286 close_rect: Rect,
287}
288
289pub(crate) enum ToastPress<Msg> {
291 Held,
293 Dismissed,
295 Action(Msg),
297 Pressed(Msg),
299}
300
301pub(crate) struct ToastStack<Msg> {
303 entries: Vec<Entry<Msg>>,
304 corner: Corner,
305 next_id: u64,
306}
307
308impl<Msg> Default for ToastStack<Msg> {
309 fn default() -> Self {
310 Self { entries: Vec::new(), corner: Corner::default(), next_id: 0 }
311 }
312}
313
314impl<Msg> ToastStack<Msg> {
315 pub(crate) fn push(&mut self, toast: Toast<Msg>) {
317 let remaining =
318 toast.duration.unwrap_or(if toast.action.is_some() { ACTION_DURATION } else { DEFAULT_DURATION });
319 if let Some(key) = &toast.key
320 && let Some(entry) = self.entries.iter_mut().find(|e| !e.leaving && e.toast.key.as_ref() == Some(key))
321 {
322 entry.toast = toast;
323 entry.remaining = remaining;
324 return;
325 }
326 self.next_id += 1;
327 let id = WidgetId::ROOT.child(&Key::Named(format!("quvyta.toast.{}", self.next_id)), "Toast");
328 self.entries.push(Entry {
329 toast,
330 id,
331 remaining,
332 ticked: None,
333 shown_at: None,
334 leaving: false,
335 left_at: None,
336 waiting: false,
337 rect: Rect::default(),
338 action_rect: Rect::default(),
339 close_rect: Rect::default(),
340 });
341 }
342
343 pub(crate) fn dismiss(&mut self, key: &str) {
345 for entry in self.entries.iter_mut().filter(|e| e.toast.key.as_deref() == Some(key)) {
346 entry.leaving = true;
347 }
348 }
349
350 pub(crate) fn set_corner(&mut self, corner: Corner) {
352 self.corner = corner;
353 }
354
355 pub(crate) fn press(&mut self, x: i32, y: i32) -> Option<ToastPress<Msg>> {
358 let entry = self.entries.iter_mut().rev().find(|e| !e.leaving && e.rect.contains(x, y))?;
359 if entry.close_rect.contains(x, y) {
360 entry.leaving = true;
361 return Some(ToastPress::Dismissed);
362 }
363 if entry.action_rect.contains(x, y)
364 && let Some((_, message)) = entry.toast.action.take()
365 {
366 entry.leaving = true;
367 return Some(ToastPress::Action(message));
368 }
369 Some(entry.toast.on_press.as_ref().map_or(ToastPress::Held, |message| ToastPress::Pressed(message())))
370 }
371
372 pub(crate) fn paint(&mut self, cx: &mut PaintCx<'_>) {
375 let now = cx.now();
376 let enter = cx.env().theme().motion().enter;
377 let reduced = cx.reduced_motion();
378 let pointer = cx.pointer_anywhere();
379 for entry in &mut self.entries {
380 if entry.waiting {
381 continue;
383 }
384 let hovered = pointer.is_some_and(|(x, y)| entry.rect.contains(x, y));
385 let since = entry.ticked.unwrap_or(now);
386 if !hovered && !entry.leaving {
387 entry.remaining = entry.remaining.saturating_sub(now.saturating_sub(since));
388 }
389 entry.ticked = Some(now);
390 entry.shown_at.get_or_insert(now);
391 if entry.remaining.is_zero() {
392 entry.leaving = true;
393 }
394 if entry.leaving && entry.left_at.is_none() {
395 entry.left_at = Some(now);
396 }
397 }
398 self.entries.retain(|entry| {
400 !(entry.waiting && entry.leaving) && entry.left_at.is_none_or(|left| !reduced && now < left + enter)
401 });
402
403 let screen = cx.clip();
404 let style = cx.style("toast", None, &[]);
405 let padding = style.padding();
406 let width = MAX_WIDTH.min(screen.width.saturating_sub(4));
407 let corner = self.corner;
408 let x = if corner.right() { screen.right() - 2 - i32::from(width) } else { screen.x + 2 };
409 let modals = cx.modal_surfaces(screen);
410 let (top_limit, bottom_limit) = room(screen, Rect::new(x, screen.y, width, screen.height), corner, &modals);
411 let mut y = if corner.bottom() { bottom_limit - 1 } else { top_limit + 1 };
412 let mut placing = width >= 12;
413 for index in (0..self.entries.len()).rev() {
415 let icon_width = text::width(&cx.env().icons().glyph(self.entries[index].toast.kind.icon()));
416 let body_width = width.saturating_sub(padding.horizontal().saturating_add(text_indent(icon_width)));
417 let body_lines =
418 self.entries[index].toast.body.as_deref().map_or(0, |body| text::wrap(body, body_width).len());
419 let height = cells::sum([padding.vertical(), 1, clamp_u16(i32::try_from(body_lines).unwrap_or(0))]);
420 let top = if corner.bottom() { y - i32::from(height) } else { y };
421 placing = placing && top >= top_limit && top + i32::from(height) <= bottom_limit;
424 let entry = &mut self.entries[index];
425 if !placing {
426 if !entry.waiting {
427 entry.waiting = true;
428 entry.ticked = None;
429 entry.shown_at = None;
430 entry.rect = Rect::default();
431 }
432 continue;
433 }
434 if entry.waiting {
435 entry.waiting = false;
437 entry.ticked = Some(now);
438 entry.shown_at = Some(now);
439 }
440 let arrived = cx.progress_since(entry.shown_at.unwrap_or(now), enter, Easing::EaseOut);
442 let gone = entry.left_at.map_or(0.0, |left| cx.progress_since(left, enter, Easing::EaseIn));
443 let presence = (arrived - gone).clamp(0.0, 1.0);
444 let offset = i32::from(steps(1.0 - presence, width + 2));
445 let shift = if corner.right() { offset } else { -offset };
446 entry.rect = Rect::new(x, top, width, height);
447 paint_entry(cx, entry, Rect::new(x + shift, top, width, height), presence);
448 if !entry.leaving {
449 cx.register_hit_as(entry.rect, entry.id);
450 if !pointer.is_some_and(|(px, py)| entry.rect.contains(px, py)) {
451 cx.request_frame_in(entry.remaining);
452 }
453 }
454 y = if corner.bottom() { top - 1 } else { top + i32::from(height) + 1 };
455 }
456 }
457}
458
459fn room(screen: Rect, column: Rect, corner: Corner, modals: &[Rect]) -> (i32, i32) {
463 let (mut top, mut bottom) = (screen.y, screen.bottom());
464 for modal in modals.iter().filter(|modal| modal.x < column.right() && column.x < modal.right()) {
465 if corner.bottom() {
466 top = top.max(modal.bottom() + 1);
467 } else {
468 bottom = bottom.min(modal.y - 1);
469 }
470 }
471 (top, bottom)
472}
473
474fn paint_entry<Msg>(cx: &mut PaintCx<'_>, entry: &mut Entry<Msg>, rect: Rect, presence: f32) {
475 let pointer = cx.pointer_anywhere();
476 let raised = entry.toast.on_press.is_some() && pointer.is_some_and(|(x, y)| entry.rect.contains(x, y));
478 let states = if raised { vec![crate::theme::State::Hover] } else { Vec::new() };
479 let style = cx.style("toast", None, &states);
480 let padding = style.padding();
481 let background = style.text().bg.unwrap_or_else(|| cx.color("overlay"));
482 let grounds = cx.grounds_around(rect);
486 let lift = cx.lift_for(rect, &grounds, Some(background));
487 let surface = lift.map_or(background, |lift| lift.apply(background));
488 let blend = |color: Option<Rgb>| color.map(|c| surface.mix(c, presence));
490 let status = cx.color(entry.toast.kind.name());
491 cx.clear(rect, background);
492 cx.fill(Rect::new(rect.x, rect.y, 1, rect.height), background.mix(status, presence));
493
494 let inner = rect.inset(padding);
495 let content_x = inner.x + 1;
496 let icon = cx.env().icons().glyph(entry.toast.kind.icon()).into_owned();
497 let icon_width = text::width(&icon);
498 match entry.toast.icon_motion.clone().filter(|_| !cx.reduced_motion()) {
499 Some(animation) => {
500 let cell = Rect::new(content_x, inner.y, 1, 1);
503 let spinner = Spinner::new().animation(animation).variant(entry.toast.kind.name());
504 Widget::<()>::paint(&spinner, cx, cell);
505 cx.tint(cell, background, 1.0 - presence);
506 }
507 None => {
508 let style = CellStyle { fg: blend(Some(status)), ..CellStyle::default() };
509 cx.text(content_x, inner.y, &icon, style, icon_width);
510 }
511 }
512 let text_x = inner.x + i32::from(text_indent(icon_width));
515
516 let slid = rect.x - entry.rect.x;
518 let mark_x = inner.right() - i32::from(close_mark::WIDTH) + 1;
522 let mark = close_mark::paint(cx, mark_x, inner.y, false);
523 cx.tint(mark, background, 1.0 - presence);
524 entry.close_rect = Rect::new(mark.x - slid, mark.y, mark.width, 1);
525 let mut right = mark_x - 1;
527 entry.action_rect = Rect::default();
528 if let Some((label, _)) = &entry.toast.action {
529 let label_width = text::width(label).saturating_add(2);
530 let action = Rect::new(right - i32::from(label_width), inner.y, label_width, 1);
531 let target = Rect::new(action.x - slid, action.y, action.width, 1);
532 let mut states = if raised { vec![crate::theme::State::Active] } else { Vec::new() };
534 if pointer.is_some_and(|(x, y)| target.contains(x, y)) {
535 states.push(crate::theme::State::Hover);
536 }
537 let action_style = cx.style("toast-action", None, &states).text();
538 if let Some(bg) = action_style.bg {
539 cx.fill(action, background.mix(bg, presence));
540 }
541 cx.text(
542 action.x + 1,
543 action.y,
544 label,
545 CellStyle { fg: blend(action_style.fg), bg: None, ..action_style },
546 label_width,
547 );
548 entry.action_rect = target;
549 right = action.x - 2;
550 }
551
552 let title_style = cx.style("toast-title", None, &[]).text();
553 let budget = clamp_u16(right - text_x);
554 let title = text::truncate(&entry.toast.title, budget).into_owned();
555 cx.text(text_x, inner.y, &title, CellStyle { fg: blend(title_style.fg), bg: None, ..title_style }, budget);
556 if let Some(body) = &entry.toast.body {
557 let body_style = cx.style("toast-body", None, &[]).text();
558 let body_width = clamp_u16(inner.right() - text_x);
559 for (row, line) in text::wrap(body, body_width).iter().enumerate() {
560 let y = inner.y + 1 + i32::try_from(row).unwrap_or(0);
561 cx.text(text_x, y, line, CellStyle { fg: blend(body_style.fg), bg: None, ..body_style }, body_width);
562 }
563 }
564 if let Some(lift) = lift {
565 cx.lift(rect, lift);
566 }
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572 use crate::runtime::{App, Command, Harness};
573 use crate::widget::{Length, View};
574 use crate::widgets::{Button, SpinnerStyle, Text};
575
576 #[derive(Default)]
577 struct Demo {
578 undone: u32,
579 pressed: u32,
580 opened: u32,
581 }
582
583 #[derive(Clone)]
584 enum Msg {
585 Deployed,
586 Failed,
587 Progress(u32),
588 Finish,
589 Undo,
590 Corner,
591 Press,
592 Loading(SpinnerStyle),
593 Loaded,
594 Pressable,
595 OpenLog,
596 }
597
598 impl App for Demo {
599 type Msg = Msg;
600 fn update(&mut self, msg: Msg) -> Command<Msg> {
601 match msg {
602 Msg::Deployed => Command::toast(Toast::success("Deployed api-gateway").body("v2.14.0 is live")),
603 Msg::Failed => Command::toast(Toast::danger("Build failed").action("Undo", Msg::Undo)),
604 Msg::Progress(n) => Command::toast(Toast::info(format!("Uploading {n}%")).key("upload")),
605 Msg::Finish => Command::dismiss_toast("upload"),
606 Msg::Undo => {
607 self.undone += 1;
608 Command::none()
609 }
610 Msg::Corner => Command::toast_corner(Corner::TopLeft),
611 Msg::Loading(style) => Command::toast(Toast::info("Uploading backup").icon_motion(style).key("upload")),
612 Msg::Loaded => Command::toast(Toast::success("Backup uploaded").key("upload")),
613 Msg::Press => {
614 self.pressed += 1;
615 Command::none()
616 }
617 Msg::Pressable => Command::toast(
618 Toast::success("Deployed api-gateway").action("Undo", Msg::Undo).on_press(Msg::OpenLog),
619 ),
620 Msg::OpenLog => {
621 self.opened += 1;
622 Command::none()
623 }
624 }
625 }
626 fn view(&self, ui: &mut View<'_, Msg>) {
627 ui.column(|ui| {
628 ui.add(Text::new("dashboard"));
629 ui.add(Button::new("Refresh").on_press(Msg::Press)).width(Length::Cells(60));
630 });
631 }
632 }
633
634 #[test]
635 fn slides_in_at_the_bottom_right_and_leaves_after_its_duration() {
636 let mut h = Harness::new(Demo::default(), 60, 10);
637 h.send(Msg::Deployed);
638 assert!(!h.screen().contains("Deployed"), "starts beyond the edge: {}", h.screen());
639 h.advance(Duration::from_millis(200));
640 let screen = h.screen();
641 let lines: Vec<&str> = screen.lines().collect();
642 assert_eq!(lines[6], " ✓ Deployed api-gateway ×", "{screen}");
643 assert_eq!(lines[7], " v2.14.0 is live");
644 let theme = h.env().theme();
645 assert_eq!(h.bg(2, 6), theme.color("success"));
646 assert_eq!(h.fg(5, 6), theme.color("success"));
647 assert_eq!(h.bg(20, 5), theme.color("overlay"));
648 h.advance(Duration::from_secs(5));
649 h.advance(Duration::from_millis(200));
650 assert!(!h.screen().contains("Deployed"), "{}", h.screen());
651 }
652
653 #[test]
654 fn hovering_pauses_the_countdown() {
655 let mut h = Harness::new(Demo::default(), 60, 10);
656 h.set_reduced_motion(true).send(Msg::Deployed);
657 h.hover(30, 6).advance(Duration::from_secs(10));
658 assert!(h.screen().contains("Deployed"));
659 h.hover(0, 0).advance(Duration::from_secs(4));
660 assert!(h.screen().contains("Deployed"));
661 h.advance(Duration::from_secs(2));
662 assert!(!h.screen().contains("Deployed"));
663 }
664
665 #[test]
666 fn the_action_sends_its_message_and_dismisses_without_reaching_below() {
667 let mut h = Harness::new(Demo::default(), 60, 10);
668 h.set_reduced_motion(true).send(Msg::Failed);
669 h.click_text("Undo");
670 assert_eq!((h.app().undone, h.app().pressed), (1, 0));
671 assert!(!h.screen().contains("Build failed"));
672 h.send(Msg::Failed).send(Msg::Deployed);
673 let screen = h.screen();
674 let failed = h.find("Build failed").expect("stacked");
675 let deployed = h.find("Deployed").expect("newest");
676 assert!(failed.1 < deployed.1, "newest is nearest the corner: {screen}");
677 h.click_text("Undo");
678 assert!(!h.screen().contains("Build failed"));
679 assert!(h.screen().contains("Deployed"), "only the toast whose action was pressed left");
680 assert_eq!(h.app().undone, 2);
681 }
682
683 #[test]
684 fn a_press_on_the_body_neither_dismisses_nor_reaches_below() {
685 let mut h = Harness::new(Demo::default(), 60, 10);
686 h.set_reduced_motion(true).send(Msg::Deployed);
687 h.click_text("Deployed api-gateway").click_text("v2.14.0").click(3, 6).click(50, 7);
688 assert!(h.screen().contains("Deployed api-gateway"), "{}", h.screen());
689 assert_eq!((h.app().pressed, h.app().opened), (0, 0), "the presses stayed on the toast");
690 h.hover(0, 0).advance(Duration::from_secs(6));
691 assert!(!h.screen().contains("Deployed"), "the toast still leaves on its own");
692 }
693
694 #[test]
695 fn a_pressable_toast_sends_its_message_stays_and_rises_under_the_pointer() {
696 let mut h = Harness::new(Demo::default(), 60, 10);
697 h.set_reduced_motion(true).send(Msg::Pressable);
698 let theme = h.env().theme().clone();
699 let (x, y) = h.find("Deployed").expect("toast");
700 let (cx, cy) = (u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0));
701 assert_eq!(h.bg(cx, cy), theme.color("overlay"), "at rest a pressable toast looks like any other");
702 h.hover(x, y);
703 assert_eq!(h.bg(cx, cy), theme.color("active"), "under the pointer it rises one step");
704 let (ux, uy) = h.find("Undo").expect("action");
705 let action = theme.style("toast-action", None, &[crate::theme::State::Active]).paint("bg");
706 let action = action.map(|paint| paint.at(0.0));
707 assert_eq!(h.bg(u16::try_from(ux).unwrap_or(0), u16::try_from(uy).unwrap_or(0)), action);
708 assert_ne!(action, theme.color("active"), "the action stays a step above the raised toast");
709 let (mx, my) = h.find("×").expect("close mark");
710 let (mx, my) = (u16::try_from(mx).unwrap_or(0), u16::try_from(my).unwrap_or(0));
711 assert_eq!(h.fg(mx, my), mark_rest(&theme), "the close mark keeps its whisper");
712
713 h.click(x, y).click(x, y);
714 assert_eq!((h.app().opened, h.app().pressed, h.app().undone), (2, 0, 0));
715 assert!(h.screen().contains("Deployed"), "a press on the body leaves the toast");
716 h.click_text("Undo");
717 assert_eq!((h.app().opened, h.app().undone), (2, 1), "the action is its own target");
718 assert!(!h.screen().contains("Deployed"));
719
720 h.send(Msg::Pressable).click(i32::from(mx), i32::from(my));
721 assert!(!h.screen().contains("Deployed"), "the close mark only closes");
722 assert_eq!(h.app().opened, 2);
723 }
724
725 fn mark_rest(theme: &crate::theme::Theme) -> Option<Rgb> {
726 theme.style("close-mark", None, &[]).paint("fg").map(|paint| paint.at(0.0))
727 }
728
729 #[test]
730 fn the_close_mark_is_the_shared_three_cells_and_dismisses() {
731 let mut h = Harness::new(Demo::default(), 60, 10);
732 h.set_reduced_motion(true).send(Msg::Deployed);
733 let theme = h.env().theme().clone();
734 let mark = |states: &[crate::theme::State], key: &str| {
735 theme.style("close-mark", None, states).paint(key).map(|paint| paint.at(0.0))
736 };
737 let rest = mark_rest(&theme);
738 let lit = mark(&[crate::theme::State::Hover], "bg");
739 assert_eq!(h.screen().lines().nth(6).map(|line| line.chars().nth(55)), Some(Some('×')));
740 assert_eq!(h.fg(55, 6), rest, "a whisper while the toast is left alone");
741 for (x, y) in [(30, 6), (8, 7), (53, 6), (57, 6), (55, 7)] {
742 h.hover(x, y);
743 assert_eq!(h.fg(55, 6), rest, "pointing at the toast at {x},{y} leaves the glyph alone");
744 assert_eq!([h.bg(54, 6), h.bg(55, 6), h.bg(56, 6)], [theme.color("overlay"); 3], "nothing lit");
745 }
746 h.hover(56, 6);
747 let cells = [h.bg(54, 6), h.bg(55, 6), h.bg(56, 6)];
748 assert_eq!(cells, [lit, lit, lit], "the three cells light together, as on tabs");
749 assert_ne!(h.fg(55, 6), rest, "and the glyph with them");
750 assert_eq!(h.bg(53, 6), theme.color("overlay"));
751 assert_eq!(h.bg(57, 6), theme.color("overlay"), "the lit mark keeps one cell of toast after it");
752 h.click(53, 6).click(57, 6);
753 assert!(h.screen().contains("Deployed"), "the cells beside the mark do not close");
754 for x in [54, 55, 56] {
755 if x > 54 {
756 h.send(Msg::Deployed);
757 }
758 h.click(x, 6);
759 assert!(!h.screen().contains("Deployed"), "a press on mark cell {x} dismisses");
760 }
761 assert_eq!(h.app().pressed, 0, "the press stays on the toast");
762 }
763
764 #[test]
765 fn an_action_keeps_a_cell_of_surface_before_the_close_mark() {
766 let mut h = Harness::new(Demo::default(), 60, 10);
767 h.set_reduced_motion(true).send(Msg::Failed);
768 let (x, y) = h.find("Undo").expect("action");
769 let line = h.screen().lines().nth(usize::try_from(y).unwrap_or(0)).unwrap_or_default().to_owned();
770 assert!(line.ends_with("Undo ×"), "{line:?}");
771 let theme = h.env().theme().clone();
772 assert_eq!(h.bg(u16::try_from(x + 5).unwrap_or(0), 7), theme.color("overlay"), "the gap");
773 }
774
775 #[test]
776 fn an_animated_icon_plays_in_the_kind_colour_and_settles_into_the_kind_icon() {
777 let mut h = Harness::new(Demo::default(), 60, 10);
778 h.send(Msg::Loading(SpinnerStyle::Dots)).advance(Duration::from_millis(200));
779 let theme = h.env().theme().clone();
780 let row = |h: &Harness<Demo>| h.screen().lines().nth(7).unwrap_or_default().to_owned();
781 let first = row(&h);
782 let frame = first.chars().nth(5);
783 let dots = h.env().icons().animation(SpinnerStyle::Dots.animation()).expect("built in");
784 let frames: Vec<&str> = dots.frames().iter().map(|dots| dots.glyph(h.env().icons().mode())).collect();
785 assert!(frame.is_some_and(|frame| frames.contains(&frame.to_string().as_str())), "{first}");
786 let text: String = first.chars().skip(8).collect();
787 assert_eq!(text, format!("Uploading backup{}×", " ".repeat(31)), "text in its usual column");
788 assert_eq!(h.fg(5, 7), theme.color("info"), "the spinner takes the kind's colour");
789 h.advance(Duration::from_millis(80));
790 assert_ne!(row(&h).chars().nth(5), frame, "it moves");
791
792 h.send(Msg::Loaded).advance(Duration::from_millis(10));
793 let done = row(&h);
794 assert_eq!(done.chars().nth(5), Some('✓'), "the keyed toast settles into its icon: {done}");
795 assert_eq!(done.chars().nth(8), Some('B'), "the text did not move");
796 assert_eq!(h.fg(5, 7), theme.color("success"));
797 }
798
799 #[test]
800 fn an_animated_icon_blends_in_with_the_toast_and_reduced_motion_stands_still() {
801 let mut h = Harness::new(Demo::default(), 60, 10);
802 h.send(Msg::Loading(SpinnerStyle::Dots)).advance(Duration::from_millis(1));
803 h.advance(Duration::from_millis(60));
804 let (x, y) = h.find("Uploading").expect("mid-slide");
805 let (x, y) = (u16::try_from(x - 3).unwrap_or(0), u16::try_from(y).unwrap_or(0));
806 let theme = h.env().theme().clone();
807 let (fg, info, overlay) = (h.fg(x, y), theme.color("info"), theme.color("overlay"));
808 assert_ne!(fg, info, "mid-slide the spinner has not reached its colour yet");
809 assert_ne!(fg, overlay, "but it is on its way");
810 let title_full = theme.style("toast-title", None, &[]).paint("fg").map(|paint| paint.at(0.0));
811 assert_ne!(h.fg(x + 3, y), title_full, "arriving with the title, which blends the same way");
812
813 let mut still = Harness::new(Demo::default(), 60, 10);
814 still.set_reduced_motion(true).send(Msg::Loading(SpinnerStyle::Pulse));
815 let line = still.screen().lines().nth(7).unwrap_or_default().to_owned();
816 assert_eq!(line.chars().nth(5), Some('ℹ'), "reduced motion shows the kind's icon: {line}");
817 let before = still.screen();
818 still.advance(Duration::from_millis(900));
819 assert_eq!(still.screen(), before);
820 assert_eq!(still.fg(5, 7), theme.color("info"));
821 }
822
823 #[test]
824 fn keyed_toasts_update_in_place_and_are_dismissed_by_key() {
825 let mut h = Harness::new(Demo::default(), 60, 10);
826 h.set_reduced_motion(true).send(Msg::Progress(10)).send(Msg::Progress(60));
827 let screen = h.screen();
828 assert!(screen.contains("Uploading 60%") && !screen.contains("Uploading 10%"), "{screen}");
829 h.send(Msg::Finish);
830 assert!(!h.screen().contains("Uploading"));
831 }
832
833 #[test]
834 fn corner_can_move_to_the_top_left() {
835 let mut h = Harness::new(Demo::default(), 60, 10);
836 h.set_reduced_motion(true).send(Msg::Corner).send(Msg::Deployed);
837 assert_eq!(h.find("Deployed"), Some((8, 2)));
838 }
839}