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
116fn action_width(label: &str) -> u16 {
118 text::width(label).saturating_add(2)
119}
120
121fn title_lines<Msg>(toast: &Toast<Msg>, inner_width: u16, icon_width: u16) -> Vec<String> {
124 let rest = inner_width.saturating_sub(text_indent(icon_width)).max(1);
125 let mut first = rest.saturating_sub(close_mark::WIDTH);
127 if let Some((label, _)) = &toast.action {
128 first = first.saturating_sub(action_width(label).saturating_add(2));
129 }
130 let title = toast.title.as_str();
131 let Some(line) = text::wrap_ranges(title, first.max(1)).into_iter().next() else {
132 return vec![String::new()];
133 };
134 let remainder = title[line.end..].trim_start();
135 if remainder.is_empty() {
136 return vec![title.to_owned()];
137 }
138 let mut lines = vec![title[line].trim_end().to_owned()];
139 lines.extend(text::wrap(remainder, rest));
140 lines
141}
142
143pub struct Toast<Msg> {
158 kind: ToastKind,
159 title: String,
160 body: Option<String>,
161 action: Option<(String, Msg)>,
162 duration: Option<Duration>,
163 key: Option<String>,
164 icon_motion: Option<AnimationName>,
165 on_press: Option<Box<dyn Fn() -> Msg>>,
167}
168
169impl<Msg> Toast<Msg> {
170 #[must_use]
172 pub fn new(kind: ToastKind, title: impl Into<String>) -> Self {
173 Self {
174 kind,
175 title: title.into(),
176 body: None,
177 action: None,
178 duration: None,
179 key: None,
180 icon_motion: None,
181 on_press: None,
182 }
183 }
184
185 #[must_use]
187 pub fn success(title: impl Into<String>) -> Self {
188 Self::new(ToastKind::Success, title)
189 }
190
191 #[must_use]
193 pub fn warning(title: impl Into<String>) -> Self {
194 Self::new(ToastKind::Warning, title)
195 }
196
197 #[must_use]
199 pub fn danger(title: impl Into<String>) -> Self {
200 Self::new(ToastKind::Danger, title)
201 }
202
203 #[must_use]
205 pub fn info(title: impl Into<String>) -> Self {
206 Self::new(ToastKind::Info, title)
207 }
208
209 #[must_use]
211 pub fn body(mut self, body: impl Into<String>) -> Self {
212 self.body = Some(body.into());
213 self
214 }
215
216 #[must_use]
218 pub fn action(mut self, label: impl Into<String>, message: Msg) -> Self {
219 self.action = Some((label.into(), message));
220 self
221 }
222
223 #[must_use]
226 pub fn duration(mut self, duration: Duration) -> Self {
227 self.duration = Some(duration);
228 self
229 }
230
231 #[must_use]
234 pub fn key(mut self, key: impl Into<String>) -> Self {
235 self.key = Some(key.into());
236 self
237 }
238
239 #[must_use]
255 pub fn icon_motion(mut self, animation: impl Into<AnimationName>) -> Self {
256 self.icon_motion = Some(animation.into());
257 self
258 }
259}
260
261impl<Msg: 'static> Toast<Msg> {
262 pub(crate) fn map<B: 'static>(self, map: std::sync::Arc<dyn Fn(Msg) -> B + Send + Sync>) -> Toast<B> {
264 let action = self.action.map(|(label, message)| (label, map(message)));
265 let on_press = self.on_press.map(|press| Box::new(move || map(press())) as Box<dyn Fn() -> B>);
266 Toast {
267 kind: self.kind,
268 title: self.title,
269 body: self.body,
270 action,
271 duration: self.duration,
272 key: self.key,
273 icon_motion: self.icon_motion,
274 on_press,
275 }
276 }
277}
278
279impl<Msg: Clone + 'static> Toast<Msg> {
280 #[must_use]
297 pub fn on_press(mut self, message: Msg) -> Self {
298 self.on_press = Some(Box::new(move || message.clone()));
299 self
300 }
301}
302
303struct Entry<Msg> {
304 toast: Toast<Msg>,
305 id: WidgetId,
306 remaining: Duration,
307 ticked: Option<Duration>,
309 shown_at: Option<Duration>,
310 leaving: bool,
311 left_at: Option<Duration>,
312 waiting: bool,
315 rect: Rect,
316 action_rect: Rect,
317 close_rect: Rect,
318}
319
320pub(crate) enum ToastPress<Msg> {
322 Held,
324 Dismissed,
326 Action(Msg),
328 Pressed(Msg),
330}
331
332pub(crate) struct ToastStack<Msg> {
334 entries: Vec<Entry<Msg>>,
335 corner: Corner,
336 next_id: u64,
337}
338
339impl<Msg> Default for ToastStack<Msg> {
340 fn default() -> Self {
341 Self { entries: Vec::new(), corner: Corner::default(), next_id: 0 }
342 }
343}
344
345impl<Msg> ToastStack<Msg> {
346 pub(crate) fn push(&mut self, toast: Toast<Msg>) {
348 let remaining =
349 toast.duration.unwrap_or(if toast.action.is_some() { ACTION_DURATION } else { DEFAULT_DURATION });
350 if let Some(key) = &toast.key
351 && let Some(entry) = self.entries.iter_mut().find(|e| !e.leaving && e.toast.key.as_ref() == Some(key))
352 {
353 entry.toast = toast;
354 entry.remaining = remaining;
355 return;
356 }
357 self.next_id += 1;
358 let id = WidgetId::ROOT.child(&Key::Named(format!("quvyta.toast.{}", self.next_id)), "Toast");
359 self.entries.push(Entry {
360 toast,
361 id,
362 remaining,
363 ticked: None,
364 shown_at: None,
365 leaving: false,
366 left_at: None,
367 waiting: false,
368 rect: Rect::default(),
369 action_rect: Rect::default(),
370 close_rect: Rect::default(),
371 });
372 }
373
374 pub(crate) fn dismiss(&mut self, key: &str) {
376 for entry in self.entries.iter_mut().filter(|e| e.toast.key.as_deref() == Some(key)) {
377 entry.leaving = true;
378 }
379 }
380
381 pub(crate) fn set_corner(&mut self, corner: Corner) {
383 self.corner = corner;
384 }
385
386 pub(crate) fn press(&mut self, x: i32, y: i32) -> Option<ToastPress<Msg>> {
389 let entry = self.entries.iter_mut().rev().find(|e| !e.leaving && e.rect.contains(x, y))?;
390 if entry.close_rect.contains(x, y) {
391 entry.leaving = true;
392 return Some(ToastPress::Dismissed);
393 }
394 if entry.action_rect.contains(x, y)
395 && let Some((_, message)) = entry.toast.action.take()
396 {
397 entry.leaving = true;
398 return Some(ToastPress::Action(message));
399 }
400 Some(entry.toast.on_press.as_ref().map_or(ToastPress::Held, |message| ToastPress::Pressed(message())))
401 }
402
403 pub(crate) fn paint(&mut self, cx: &mut PaintCx<'_>) {
406 let now = cx.now();
407 let enter = cx.env().theme().motion().enter;
408 let reduced = cx.reduced_motion();
409 let pointer = cx.pointer_anywhere();
410 for entry in &mut self.entries {
411 if entry.waiting {
412 continue;
414 }
415 let hovered = pointer.is_some_and(|(x, y)| entry.rect.contains(x, y));
416 let since = entry.ticked.unwrap_or(now);
417 if !hovered && !entry.leaving {
418 entry.remaining = entry.remaining.saturating_sub(now.saturating_sub(since));
419 }
420 entry.ticked = Some(now);
421 entry.shown_at.get_or_insert(now);
422 if entry.remaining.is_zero() {
423 entry.leaving = true;
424 }
425 if entry.leaving && entry.left_at.is_none() {
426 entry.left_at = Some(now);
427 }
428 }
429 self.entries.retain(|entry| {
431 !(entry.waiting && entry.leaving) && entry.left_at.is_none_or(|left| !reduced && now < left + enter)
432 });
433
434 let screen = cx.clip();
435 let style = cx.style("toast", None, &[]);
436 let padding = style.padding();
437 let width = MAX_WIDTH.min(screen.width.saturating_sub(4));
438 let corner = self.corner;
439 let x = if corner.right() { screen.right() - 2 - i32::from(width) } else { screen.x + 2 };
440 let modals = cx.modal_surfaces(screen);
441 let (top_limit, bottom_limit) = room(screen, Rect::new(x, screen.y, width, screen.height), corner, &modals);
442 let mut y = if corner.bottom() { bottom_limit - 1 } else { top_limit + 1 };
443 let mut placing = width >= 12;
444 for index in (0..self.entries.len()).rev() {
446 let icon_width = text::width(&cx.env().icons().glyph(self.entries[index].toast.kind.icon()));
447 let inner_width = width.saturating_sub(padding.horizontal());
448 let body_width = inner_width.saturating_sub(text_indent(icon_width));
449 let toast = &self.entries[index].toast;
450 let title_lines = title_lines(toast, inner_width, icon_width).len();
451 let body_lines = toast.body.as_deref().map_or(0, |body| text::wrap(body, body_width).len());
452 let lines = clamp_u16(i32::try_from(title_lines + body_lines).unwrap_or(i32::MAX));
453 let height = cells::sum([padding.vertical(), lines]);
454 let top = if corner.bottom() { y - i32::from(height) } else { y };
455 placing = placing && top >= top_limit && top + i32::from(height) <= bottom_limit;
458 let entry = &mut self.entries[index];
459 if !placing {
460 if !entry.waiting {
461 entry.waiting = true;
462 entry.ticked = None;
463 entry.shown_at = None;
464 entry.rect = Rect::default();
465 }
466 continue;
467 }
468 if entry.waiting {
469 entry.waiting = false;
471 entry.ticked = Some(now);
472 entry.shown_at = Some(now);
473 }
474 let arrived = cx.progress_since(entry.shown_at.unwrap_or(now), enter, Easing::EaseOut);
476 let gone = entry.left_at.map_or(0.0, |left| cx.progress_since(left, enter, Easing::EaseIn));
477 let presence = (arrived - gone).clamp(0.0, 1.0);
478 let offset = i32::from(steps(1.0 - presence, width + 2));
479 let shift = if corner.right() { offset } else { -offset };
480 entry.rect = Rect::new(x, top, width, height);
481 paint_entry(cx, entry, Rect::new(x + shift, top, width, height), presence);
482 if !entry.leaving {
483 cx.register_hit_as(entry.rect, entry.id);
484 if !pointer.is_some_and(|(px, py)| entry.rect.contains(px, py)) {
485 cx.request_frame_in(entry.remaining);
486 }
487 }
488 y = if corner.bottom() { top - 1 } else { top + i32::from(height) + 1 };
489 }
490 }
491}
492
493fn room(screen: Rect, column: Rect, corner: Corner, modals: &[Rect]) -> (i32, i32) {
497 let (mut top, mut bottom) = (screen.y, screen.bottom());
498 for modal in modals.iter().filter(|modal| modal.x < column.right() && column.x < modal.right()) {
499 if corner.bottom() {
500 top = top.max(modal.bottom() + 1);
501 } else {
502 bottom = bottom.min(modal.y - 1);
503 }
504 }
505 (top, bottom)
506}
507
508fn paint_entry<Msg>(cx: &mut PaintCx<'_>, entry: &mut Entry<Msg>, rect: Rect, presence: f32) {
509 let pointer = cx.pointer_anywhere();
510 let raised = entry.toast.on_press.is_some() && pointer.is_some_and(|(x, y)| entry.rect.contains(x, y));
512 let states = if raised { vec![crate::theme::State::Hover] } else { Vec::new() };
513 let style = cx.style("toast", None, &states);
514 let padding = style.padding();
515 let background = style.text().bg.unwrap_or_else(|| cx.color("overlay"));
516 let grounds = cx.grounds_around(rect);
520 let lift = cx.lift_for(rect, &grounds, Some(background));
521 let surface = lift.map_or(background, |lift| lift.apply(background));
522 let blend = |color: Option<Rgb>| color.map(|c| surface.mix(c, presence));
524 let status = cx.color(entry.toast.kind.name());
525 cx.clear(rect, background);
526 cx.fill(Rect::new(rect.x, rect.y, 1, rect.height), background.mix(status, presence));
527
528 let inner = rect.inset(padding);
529 let content_x = inner.x + 1;
530 let icon = cx.env().icons().glyph(entry.toast.kind.icon()).into_owned();
531 let icon_width = text::width(&icon);
532 match entry.toast.icon_motion.clone().filter(|_| !cx.reduced_motion()) {
533 Some(animation) => {
534 let cell = Rect::new(content_x, inner.y, 1, 1);
537 let spinner = Spinner::new().animation(animation).variant(entry.toast.kind.name());
538 Widget::<()>::paint(&spinner, cx, cell);
539 cx.tint(cell, background, 1.0 - presence);
540 }
541 None => {
542 let style = CellStyle { fg: blend(Some(status)), ..CellStyle::default() };
543 cx.text(content_x, inner.y, &icon, style, icon_width);
544 }
545 }
546 let text_x = inner.x + i32::from(text_indent(icon_width));
549
550 let slid = rect.x - entry.rect.x;
552 let mark_x = inner.right() - i32::from(close_mark::WIDTH) + 1;
556 let mark = close_mark::paint(cx, mark_x, inner.y, false);
557 cx.tint(mark, background, 1.0 - presence);
558 entry.close_rect = Rect::new(mark.x - slid, mark.y, mark.width, 1);
559 let mut right = mark_x - 1;
561 entry.action_rect = Rect::default();
562 if let Some((label, _)) = &entry.toast.action {
563 let label_width = action_width(label);
564 let action = Rect::new(right - i32::from(label_width), inner.y, label_width, 1);
565 let target = Rect::new(action.x - slid, action.y, action.width, 1);
566 let mut states = if raised { vec![crate::theme::State::Active] } else { Vec::new() };
568 if pointer.is_some_and(|(x, y)| target.contains(x, y)) {
569 states.push(crate::theme::State::Hover);
570 }
571 let action_style = cx.style("toast-action", None, &states).text();
572 if let Some(bg) = action_style.bg {
573 cx.fill(action, background.mix(bg, presence));
574 }
575 cx.text(
576 action.x + 1,
577 action.y,
578 label,
579 CellStyle { fg: blend(action_style.fg), bg: None, ..action_style },
580 label_width,
581 );
582 entry.action_rect = target;
583 right = action.x - 2;
584 }
585
586 let title_style = cx.style("toast-title", None, &[]).text();
587 let title_style = CellStyle { fg: blend(title_style.fg), bg: None, ..title_style };
588 let title = title_lines(&entry.toast, inner.width, icon_width);
589 let body_width = clamp_u16(inner.right() - text_x);
590 for (row, line) in title.iter().enumerate() {
591 let y = inner.y + i32::try_from(row).unwrap_or(0);
592 let budget = if row == 0 { clamp_u16(right - text_x) } else { body_width };
593 cx.text(text_x, y, line, title_style, budget);
594 }
595 if let Some(body) = &entry.toast.body {
596 let body_style = cx.style("toast-body", None, &[]).text();
597 let top = inner.y + i32::try_from(title.len()).unwrap_or(1);
598 for (row, line) in text::wrap(body, body_width).iter().enumerate() {
599 let y = top + i32::try_from(row).unwrap_or(0);
600 cx.text(text_x, y, line, CellStyle { fg: blend(body_style.fg), bg: None, ..body_style }, body_width);
601 }
602 }
603 if let Some(lift) = lift {
604 cx.lift(rect, lift);
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use crate::runtime::{App, Command, Harness};
612 use crate::widget::{Length, View};
613 use crate::widgets::{Button, SpinnerStyle, Text};
614
615 #[derive(Default)]
616 struct Demo {
617 undone: u32,
618 pressed: u32,
619 opened: u32,
620 }
621
622 #[derive(Clone)]
623 enum Msg {
624 Deployed,
625 Failed,
626 Progress(u32),
627 Finish,
628 Undo,
629 Corner,
630 Press,
631 Loading(SpinnerStyle),
632 Loaded,
633 Pressable,
634 OpenLog,
635 }
636
637 impl App for Demo {
638 type Msg = Msg;
639 fn update(&mut self, msg: Msg) -> Command<Msg> {
640 match msg {
641 Msg::Deployed => Command::toast(Toast::success("Deployed api-gateway").body("v2.14.0 is live")),
642 Msg::Failed => Command::toast(Toast::danger("Build failed").action("Undo", Msg::Undo)),
643 Msg::Progress(n) => Command::toast(Toast::info(format!("Uploading {n}%")).key("upload")),
644 Msg::Finish => Command::dismiss_toast("upload"),
645 Msg::Undo => {
646 self.undone += 1;
647 Command::none()
648 }
649 Msg::Corner => Command::toast_corner(Corner::TopLeft),
650 Msg::Loading(style) => Command::toast(Toast::info("Uploading backup").icon_motion(style).key("upload")),
651 Msg::Loaded => Command::toast(Toast::success("Backup uploaded").key("upload")),
652 Msg::Press => {
653 self.pressed += 1;
654 Command::none()
655 }
656 Msg::Pressable => Command::toast(
657 Toast::success("Deployed api-gateway").action("Undo", Msg::Undo).on_press(Msg::OpenLog),
658 ),
659 Msg::OpenLog => {
660 self.opened += 1;
661 Command::none()
662 }
663 }
664 }
665 fn view(&self, ui: &mut View<'_, Msg>) {
666 ui.column(|ui| {
667 ui.add(Text::new("dashboard"));
668 ui.add(Button::new("Refresh").on_press(Msg::Press)).width(Length::Cells(60));
669 });
670 }
671 }
672
673 #[test]
674 fn slides_in_at_the_bottom_right_and_leaves_after_its_duration() {
675 let mut h = Harness::new(Demo::default(), 60, 10);
676 h.send(Msg::Deployed);
677 assert!(!h.screen().contains("Deployed"), "starts beyond the edge: {}", h.screen());
678 h.advance(Duration::from_millis(200));
679 let screen = h.screen();
680 let lines: Vec<&str> = screen.lines().collect();
681 assert_eq!(lines[6], " ✓ Deployed api-gateway ×", "{screen}");
682 assert_eq!(lines[7], " v2.14.0 is live");
683 let theme = h.env().theme();
684 assert_eq!(h.bg(2, 6), theme.color("success"));
685 assert_eq!(h.fg(5, 6), theme.color("success"));
686 assert_eq!(h.bg(20, 5), theme.color("overlay"));
687 h.advance(Duration::from_secs(5));
688 h.advance(Duration::from_millis(200));
689 assert!(!h.screen().contains("Deployed"), "{}", h.screen());
690 }
691
692 #[test]
693 fn hovering_pauses_the_countdown() {
694 let mut h = Harness::new(Demo::default(), 60, 10);
695 h.set_reduced_motion(true).send(Msg::Deployed);
696 h.hover(30, 6).advance(Duration::from_secs(10));
697 assert!(h.screen().contains("Deployed"));
698 h.hover(0, 0).advance(Duration::from_secs(4));
699 assert!(h.screen().contains("Deployed"));
700 h.advance(Duration::from_secs(2));
701 assert!(!h.screen().contains("Deployed"));
702 }
703
704 #[test]
705 fn the_action_sends_its_message_and_dismisses_without_reaching_below() {
706 let mut h = Harness::new(Demo::default(), 60, 10);
707 h.set_reduced_motion(true).send(Msg::Failed);
708 h.click_text("Undo");
709 assert_eq!((h.app().undone, h.app().pressed), (1, 0));
710 assert!(!h.screen().contains("Build failed"));
711 h.send(Msg::Failed).send(Msg::Deployed);
712 let screen = h.screen();
713 let failed = h.find("Build failed").expect("stacked");
714 let deployed = h.find("Deployed").expect("newest");
715 assert!(failed.1 < deployed.1, "newest is nearest the corner: {screen}");
716 h.click_text("Undo");
717 assert!(!h.screen().contains("Build failed"));
718 assert!(h.screen().contains("Deployed"), "only the toast whose action was pressed left");
719 assert_eq!(h.app().undone, 2);
720 }
721
722 #[test]
723 fn a_press_on_the_body_neither_dismisses_nor_reaches_below() {
724 let mut h = Harness::new(Demo::default(), 60, 10);
725 h.set_reduced_motion(true).send(Msg::Deployed);
726 h.click_text("Deployed api-gateway").click_text("v2.14.0").click(3, 6).click(50, 7);
727 assert!(h.screen().contains("Deployed api-gateway"), "{}", h.screen());
728 assert_eq!((h.app().pressed, h.app().opened), (0, 0), "the presses stayed on the toast");
729 h.hover(0, 0).advance(Duration::from_secs(6));
730 assert!(!h.screen().contains("Deployed"), "the toast still leaves on its own");
731 }
732
733 #[test]
734 fn a_pressable_toast_sends_its_message_stays_and_rises_under_the_pointer() {
735 let mut h = Harness::new(Demo::default(), 60, 10);
736 h.set_reduced_motion(true).send(Msg::Pressable);
737 let theme = h.env().theme().clone();
738 let (x, y) = h.find("Deployed").expect("toast");
739 let (cx, cy) = (u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0));
740 assert_eq!(h.bg(cx, cy), theme.color("overlay"), "at rest a pressable toast looks like any other");
741 h.hover(x, y);
742 assert_eq!(h.bg(cx, cy), theme.color("active"), "under the pointer it rises one step");
743 let (ux, uy) = h.find("Undo").expect("action");
744 let action = theme.style("toast-action", None, &[crate::theme::State::Active]).paint("bg");
745 let action = action.map(|paint| paint.at(0.0));
746 assert_eq!(h.bg(u16::try_from(ux).unwrap_or(0), u16::try_from(uy).unwrap_or(0)), action);
747 assert_ne!(action, theme.color("active"), "the action stays a step above the raised toast");
748 let (mx, my) = h.find("×").expect("close mark");
749 let (mx, my) = (u16::try_from(mx).unwrap_or(0), u16::try_from(my).unwrap_or(0));
750 assert_eq!(h.fg(mx, my), mark_rest(&theme), "the close mark keeps its whisper");
751
752 h.click(x, y).click(x, y);
753 assert_eq!((h.app().opened, h.app().pressed, h.app().undone), (2, 0, 0));
754 assert!(h.screen().contains("Deployed"), "a press on the body leaves the toast");
755 h.click_text("Undo");
756 assert_eq!((h.app().opened, h.app().undone), (2, 1), "the action is its own target");
757 assert!(!h.screen().contains("Deployed"));
758
759 h.send(Msg::Pressable).click(i32::from(mx), i32::from(my));
760 assert!(!h.screen().contains("Deployed"), "the close mark only closes");
761 assert_eq!(h.app().opened, 2);
762 }
763
764 fn mark_rest(theme: &crate::theme::Theme) -> Option<Rgb> {
765 theme.style("close-mark", None, &[]).paint("fg").map(|paint| paint.at(0.0))
766 }
767
768 #[test]
769 fn the_close_mark_is_the_shared_three_cells_and_dismisses() {
770 let mut h = Harness::new(Demo::default(), 60, 10);
771 h.set_reduced_motion(true).send(Msg::Deployed);
772 let theme = h.env().theme().clone();
773 let mark = |states: &[crate::theme::State], key: &str| {
774 theme.style("close-mark", None, states).paint(key).map(|paint| paint.at(0.0))
775 };
776 let rest = mark_rest(&theme);
777 let lit = mark(&[crate::theme::State::Hover], "bg");
778 assert_eq!(h.screen().lines().nth(6).map(|line| line.chars().nth(55)), Some(Some('×')));
779 assert_eq!(h.fg(55, 6), rest, "a whisper while the toast is left alone");
780 for (x, y) in [(30, 6), (8, 7), (53, 6), (57, 6), (55, 7)] {
781 h.hover(x, y);
782 assert_eq!(h.fg(55, 6), rest, "pointing at the toast at {x},{y} leaves the glyph alone");
783 assert_eq!([h.bg(54, 6), h.bg(55, 6), h.bg(56, 6)], [theme.color("overlay"); 3], "nothing lit");
784 }
785 h.hover(56, 6);
786 let cells = [h.bg(54, 6), h.bg(55, 6), h.bg(56, 6)];
787 assert_eq!(cells, [lit, lit, lit], "the three cells light together, as on tabs");
788 assert_ne!(h.fg(55, 6), rest, "and the glyph with them");
789 assert_eq!(h.bg(53, 6), theme.color("overlay"));
790 assert_eq!(h.bg(57, 6), theme.color("overlay"), "the lit mark keeps one cell of toast after it");
791 h.click(53, 6).click(57, 6);
792 assert!(h.screen().contains("Deployed"), "the cells beside the mark do not close");
793 for x in [54, 55, 56] {
794 if x > 54 {
795 h.send(Msg::Deployed);
796 }
797 h.click(x, 6);
798 assert!(!h.screen().contains("Deployed"), "a press on mark cell {x} dismisses");
799 }
800 assert_eq!(h.app().pressed, 0, "the press stays on the toast");
801 }
802
803 #[test]
804 fn an_action_keeps_a_cell_of_surface_before_the_close_mark() {
805 let mut h = Harness::new(Demo::default(), 60, 10);
806 h.set_reduced_motion(true).send(Msg::Failed);
807 let (x, y) = h.find("Undo").expect("action");
808 let line = h.screen().lines().nth(usize::try_from(y).unwrap_or(0)).unwrap_or_default().to_owned();
809 assert!(line.ends_with("Undo ×"), "{line:?}");
810 let theme = h.env().theme().clone();
811 assert_eq!(h.bg(u16::try_from(x + 5).unwrap_or(0), 7), theme.color("overlay"), "the gap");
812 }
813
814 #[test]
815 fn an_animated_icon_plays_in_the_kind_colour_and_settles_into_the_kind_icon() {
816 let mut h = Harness::new(Demo::default(), 60, 10);
817 h.send(Msg::Loading(SpinnerStyle::Dots)).advance(Duration::from_millis(200));
818 let theme = h.env().theme().clone();
819 let row = |h: &Harness<Demo>| h.screen().lines().nth(7).unwrap_or_default().to_owned();
820 let first = row(&h);
821 let frame = first.chars().nth(5);
822 let dots = h.env().icons().animation(SpinnerStyle::Dots.animation()).expect("built in");
823 let frames: Vec<&str> = dots.frames().iter().map(|dots| dots.glyph(h.env().icons().mode())).collect();
824 assert!(frame.is_some_and(|frame| frames.contains(&frame.to_string().as_str())), "{first}");
825 let text: String = first.chars().skip(8).collect();
826 assert_eq!(text, format!("Uploading backup{}×", " ".repeat(31)), "text in its usual column");
827 assert_eq!(h.fg(5, 7), theme.color("info"), "the spinner takes the kind's colour");
828 h.advance(Duration::from_millis(80));
829 assert_ne!(row(&h).chars().nth(5), frame, "it moves");
830
831 h.send(Msg::Loaded).advance(Duration::from_millis(10));
832 let done = row(&h);
833 assert_eq!(done.chars().nth(5), Some('✓'), "the keyed toast settles into its icon: {done}");
834 assert_eq!(done.chars().nth(8), Some('B'), "the text did not move");
835 assert_eq!(h.fg(5, 7), theme.color("success"));
836 }
837
838 #[test]
839 fn an_animated_icon_blends_in_with_the_toast_and_reduced_motion_stands_still() {
840 let mut h = Harness::new(Demo::default(), 60, 10);
841 h.send(Msg::Loading(SpinnerStyle::Dots)).advance(Duration::from_millis(1));
842 h.advance(Duration::from_millis(60));
843 let (x, y) = h.find("Uploading").expect("mid-slide");
844 let (x, y) = (u16::try_from(x - 3).unwrap_or(0), u16::try_from(y).unwrap_or(0));
845 let theme = h.env().theme().clone();
846 let (fg, info, overlay) = (h.fg(x, y), theme.color("info"), theme.color("overlay"));
847 assert_ne!(fg, info, "mid-slide the spinner has not reached its colour yet");
848 assert_ne!(fg, overlay, "but it is on its way");
849 let title_full = theme.style("toast-title", None, &[]).paint("fg").map(|paint| paint.at(0.0));
850 assert_ne!(h.fg(x + 3, y), title_full, "arriving with the title, which blends the same way");
851
852 let mut still = Harness::new(Demo::default(), 60, 10);
853 still.set_reduced_motion(true).send(Msg::Loading(SpinnerStyle::Pulse));
854 let line = still.screen().lines().nth(7).unwrap_or_default().to_owned();
855 assert_eq!(line.chars().nth(5), Some('ℹ'), "reduced motion shows the kind's icon: {line}");
856 let before = still.screen();
857 still.advance(Duration::from_millis(900));
858 assert_eq!(still.screen(), before);
859 assert_eq!(still.fg(5, 7), theme.color("info"));
860 }
861
862 #[test]
863 fn keyed_toasts_update_in_place_and_are_dismissed_by_key() {
864 let mut h = Harness::new(Demo::default(), 60, 10);
865 h.set_reduced_motion(true).send(Msg::Progress(10)).send(Msg::Progress(60));
866 let screen = h.screen();
867 assert!(screen.contains("Uploading 60%") && !screen.contains("Uploading 10%"), "{screen}");
868 h.send(Msg::Finish);
869 assert!(!h.screen().contains("Uploading"));
870 }
871
872 #[test]
873 fn corner_can_move_to_the_top_left() {
874 let mut h = Harness::new(Demo::default(), 60, 10);
875 h.set_reduced_motion(true).send(Msg::Corner).send(Msg::Deployed);
876 assert_eq!(h.find("Deployed"), Some((8, 2)));
877 }
878
879 struct Undoable {
881 german: bool,
882 }
883
884 impl App for Undoable {
885 type Msg = ();
886 fn update(&mut self, (): ()) -> Command<()> {
887 let (message, action) = if self.german {
888 ("Rust: Sitzung in den Papierkorb verschoben", "Rückgängig")
889 } else {
890 ("Rust: session moved to the trash", "Undo")
891 };
892 Command::toast(Toast::success(message).action(action, ()))
893 }
894 fn view(&self, ui: &mut View<'_, ()>) {
895 ui.add(Text::new("records"));
896 }
897 }
898
899 fn undoable(german: bool) -> Harness<Undoable> {
900 let mut h = Harness::new(Undoable { german }, 40, 12);
901 h.set_locale(if german { "de" } else { "en" }).set_reduced_motion(true).send(());
902 h
903 }
904
905 #[test]
906 fn at_forty_columns_a_long_message_wraps_and_the_action_stays_on_the_first_row() {
907 for german in [false, true] {
908 let h = undoable(german);
909 let screen = h.screen();
910 assert!(!screen.contains('…'), "{screen}");
911 let action = if german { "Rückgängig" } else { "Undo" };
912 let (_, action_row) = h.find(action).unwrap_or_else(|| panic!("{screen}"));
913 let (_, title_row) = h.find("Rust:").unwrap_or_else(|| panic!("{screen}"));
914 assert_eq!(action_row, title_row, "the action is on the first row: {screen}");
915 let second = screen.lines().nth(usize::try_from(title_row + 1).unwrap_or(0)).unwrap_or_default();
916 assert!(second.contains("trash") || second.contains("Papierkorb"), "{screen}");
917 assert!(screen.contains('×'), "{screen}");
918 }
919 }
920
921 #[test]
922 fn a_wrapped_toast_still_keeps_clear_of_a_modal() {
923 struct Covered;
924 impl App for Covered {
925 type Msg = ();
926 fn update(&mut self, (): ()) -> Command<()> {
927 Command::toast(Toast::success("Rust: session moved to the trash").action("Undo", ()))
928 }
929 fn view(&self, ui: &mut View<'_, ()>) {
930 ui.add_with(crate::widgets::Modal::new().title("Open"), |ui| {
931 ui.add(Text::new("Body"));
932 });
933 }
934 }
935 let mut h = Harness::new(Covered, 40, 12);
936 h.set_reduced_motion(true).send(());
937 let screen = h.screen();
938 let (_, title) = h.find("Open").unwrap_or_else(|| panic!("{screen}"));
939 let (_, body) = h.find("Body").unwrap_or_else(|| panic!("{screen}"));
940 assert!(title < body, "{screen}");
941 if let Some((_, undo)) = h.find("Undo") {
942 let modal_bottom = body + 2;
943 assert!(undo > modal_bottom, "a toast on screen sits below the dialog: {screen}");
944 }
945 }
946}