Skip to main content

gpui_kit/overlay/
toast.rs

1//! Transient notifications that report what happened without hiding a failure.
2//!
3//! A toast is a floating surface on the toast layer, so it lives here with the
4//! rest of the overlay vocabulary rather than in a module of its own: it is
5//! ordered by the same `zIndex` tokens and painted through the same deferred
6//! pinning as a dialog or a tooltip.
7//!
8//! The host mounts a [`ToastLayer`] in the window it wants notifications drawn
9//! in, and any call site adds one with [`push`]. The stack lives in the mounted
10//! layer and the global holds only a weak handle to it, so the library renders
11//! into the window it was given and reports a push it cannot deliver instead of
12//! choosing a window on the caller's behalf.
13//!
14//! Timing is truthful before it is convenient:
15//!
16//! - a failure ([`Tone::Danger`] or [`Tone::Warning`]) stays until it is
17//!   dismissed, because a report nobody saw is a report that did not happen;
18//! - a pointer resting on a toast pauses its timer, so something being read
19//!   cannot vanish mid-sentence;
20//! - the stack has a cap, and only a toast that both times out and can be
21//!   dismissed is evicted to make room.
22
23use std::rc::Rc;
24use std::time::Duration;
25
26use gpui::{
27    AnyElement, App, Context, Entity, Global, InteractiveElement, IntoElement, ParentElement,
28    Render, SharedString, StatefulInteractiveElement, Styled, WeakEntity, Window, div,
29    prelude::FluentBuilder, px,
30};
31use gpui_kit_assets::{Icon, icon};
32use gpui_kit_semantics::{LiveRegion, NodeSpec, Role, Semantic};
33use gpui_kit_theme::{ActiveTheme, Elevation, Layer, Space, Theme, TypeScale};
34use web_time::Instant;
35
36use crate::controls::button::Button;
37use crate::display::badge::Tone;
38use crate::display::status::StatusDot;
39use crate::foundation::{FocusRing, Ident, Sizable, StyledExt};
40use crate::motion::{Easing, Flipping, MotionSpec, Phase, Presence, flip};
41use crate::overlay::layer::{pinned, priority, surface};
42use crate::strings::{ActiveStrings, StringKey};
43
44/// How many toasts stand in the stack before one is evicted to make room.
45const DEFAULT_CAPACITY: usize = 3;
46
47/// The width of one notification, and the distance it travels while entering
48/// or leaving. Neither value repeats anywhere else, so both stay next to the
49/// component instead of in the token document.
50const CARD_WIDTH: f32 = 320.0;
51const TRAVEL: f32 = 8.0;
52
53type ActionHandler = Rc<dyn Fn(&mut Window, &mut App)>;
54
55/// Which corner the stack grows from.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub enum ToastCorner {
58    TopLeft,
59    TopRight,
60    BottomLeft,
61    #[default]
62    BottomRight,
63}
64
65impl ToastCorner {
66    fn is_top(self) -> bool {
67        matches!(self, Self::TopLeft | Self::TopRight)
68    }
69
70    fn is_leading(self) -> bool {
71        matches!(self, Self::TopLeft | Self::BottomLeft)
72    }
73}
74
75/// How long a toast stays on screen.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum Lifetime {
78    /// Decided by tone when the toast reaches the stack.
79    ByTone,
80    Timed(Duration),
81    Persistent,
82}
83
84struct Action {
85    label: SharedString,
86    handler: ActionHandler,
87}
88
89/// One notification: what happened, how bad it is, and at most one thing to do
90/// about it.
91pub struct Toast {
92    ident: Ident,
93    message: SharedString,
94    detail: Option<SharedString>,
95    tone: Tone,
96    action: Option<Action>,
97    dismissable: bool,
98    lifetime: Lifetime,
99}
100
101impl std::fmt::Debug for Toast {
102    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        formatter
104            .debug_struct("Toast")
105            .field("ident", &self.ident)
106            .field("message", &self.message)
107            .field("detail", &self.detail)
108            .field("tone", &self.tone)
109            .field("has_action", &self.action.is_some())
110            .field("dismissable", &self.dismissable)
111            .field("lifetime", &self.lifetime)
112            .finish()
113    }
114}
115
116impl Toast {
117    pub fn new(ident: impl Into<Ident>, message: impl Into<SharedString>) -> Self {
118        Self {
119            ident: ident.into(),
120            message: message.into(),
121            detail: None,
122            tone: Tone::default(),
123            action: None,
124            dismissable: true,
125            lifetime: Lifetime::ByTone,
126        }
127    }
128
129    pub fn tone(mut self, tone: Tone) -> Self {
130        self.tone = tone;
131        self
132    }
133
134    /// A second line, for the part of the report that does not fit in one.
135    pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
136        self.detail = Some(detail.into());
137        self
138    }
139
140    /// The one thing that can be done about this. A later call replaces an
141    /// earlier one, because a notification with two answers is a dialog.
142    pub fn action(
143        mut self,
144        label: impl Into<SharedString>,
145        handler: impl Fn(&mut Window, &mut App) + 'static,
146    ) -> Self {
147        self.action = Some(Action {
148            label: label.into(),
149            handler: Rc::new(handler),
150        });
151        self
152    }
153
154    /// Whether the toast carries a dismiss control. A toast that cannot be
155    /// dismissed renders no such control and installs no handler for it.
156    pub fn dismissable(mut self, dismissable: bool) -> Self {
157        self.dismissable = dismissable;
158        self
159    }
160
161    /// Leaves on its own after `timeout`, whatever the tone says.
162    pub fn timeout(mut self, timeout: Duration) -> Self {
163        self.lifetime = Lifetime::Timed(timeout);
164        self
165    }
166
167    /// Stays until it is dismissed.
168    pub fn persistent(mut self) -> Self {
169        self.lifetime = Lifetime::Persistent;
170        self
171    }
172
173    pub fn ident(&self) -> &Ident {
174        &self.ident
175    }
176
177    /// How long this toast has, or `None` when it stays until dismissed.
178    ///
179    /// A tone that reports a failure defaults to staying: a timer that hides a
180    /// failure turns it into something that was never reported.
181    fn allowance(&self, theme: &Theme) -> Option<Duration> {
182        match self.lifetime {
183            Lifetime::Timed(timeout) => Some(timeout),
184            Lifetime::Persistent => None,
185            Lifetime::ByTone if reports_failure(self.tone) => None,
186            Lifetime::ByTone => Some(Duration::from_millis(theme.motion.toast_ms)),
187        }
188    }
189}
190
191fn reports_failure(tone: Tone) -> bool {
192    matches!(tone, Tone::Danger | Tone::Warning)
193}
194
195fn enter_spec(theme: &Theme) -> MotionSpec {
196    MotionSpec::new(theme.motion.menu_ms, Easing::Settle.curve(theme))
197}
198
199fn exit_spec(theme: &Theme) -> MotionSpec {
200    MotionSpec::new(theme.motion.quick_ms, Easing::Exit.curve(theme))
201}
202
203/// One toast on screen, with the state that outlives a frame.
204struct Entry {
205    toast: Toast,
206    presence: Presence,
207    /// Time left before this toast leaves, or `None` while it stays.
208    remaining: Option<Duration>,
209    hovered: bool,
210    progress: f32,
211}
212
213impl Entry {
214    fn new(toast: Toast, remaining: Option<Duration>, theme: &Theme) -> Self {
215        let mut presence = Presence::hidden(enter_spec(theme), exit_spec(theme));
216        presence.show();
217        Self {
218            toast,
219            presence,
220            remaining,
221            hovered: false,
222            progress: 0.0,
223        }
224    }
225
226    fn id(&self) -> SharedString {
227        self.toast.ident.semantic_id()
228    }
229
230    fn leaving(&self) -> bool {
231        matches!(self.presence.phase(), Phase::Exiting | Phase::Gone)
232    }
233
234    /// A toast that neither times out nor offers a way out is never evicted:
235    /// the stack would otherwise drop a report nobody has seen.
236    fn evictable(&self) -> bool {
237        !self.leaving() && self.toast.dismissable && self.remaining.is_some()
238    }
239
240    /// Spends `delta` of the allowance and reports whether a timer is still
241    /// running, which is what decides if the window needs another frame.
242    fn count_down(&mut self, delta: Duration) -> bool {
243        if self.hovered || !matches!(self.presence.phase(), Phase::Entering | Phase::Present) {
244            return false;
245        }
246        let Some(remaining) = self.remaining else {
247            return false;
248        };
249        let left = remaining.saturating_sub(delta);
250        self.remaining = Some(left);
251        if left.is_zero() {
252            self.presence.hide();
253            return false;
254        }
255        true
256    }
257}
258
259/// The stack of notifications for one window.
260///
261/// The host mounts one of these where it wants toasts drawn; mounting is also
262/// what makes [`push`] deliverable.
263pub struct ToastLayer {
264    corner: ToastCorner,
265    capacity: usize,
266    entries: Vec<Entry>,
267    /// When the last frame that advanced a timer happened.
268    last_tick: Option<Instant>,
269}
270
271impl std::fmt::Debug for ToastLayer {
272    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273        formatter
274            .debug_struct("ToastLayer")
275            .field("corner", &self.corner)
276            .field("capacity", &self.capacity)
277            .field("toasts", &self.entries.len())
278            .finish()
279    }
280}
281
282impl ToastLayer {
283    /// Mounts a layer and makes it the one [`push`] delivers to.
284    ///
285    /// A second layer takes over from the first, so an application with two
286    /// windows decides which one notifies by mounting there last.
287    pub fn new(cx: &mut Context<Self>) -> Self {
288        let handle = cx.weak_entity();
289        cx.set_global(ToastCenter {
290            layer: Some(handle),
291        });
292        Self {
293            corner: ToastCorner::default(),
294            capacity: DEFAULT_CAPACITY,
295            entries: Vec::new(),
296            last_tick: None,
297        }
298    }
299
300    pub fn corner(mut self, corner: ToastCorner) -> Self {
301        self.corner = corner;
302        self
303    }
304
305    /// How many toasts stand at once. A capacity below one is raised to one.
306    pub fn capacity(mut self, capacity: usize) -> Self {
307        self.capacity = capacity.max(1);
308        self
309    }
310
311    pub fn len(&self) -> usize {
312        self.entries.len()
313    }
314
315    pub fn is_empty(&self) -> bool {
316        self.entries.is_empty()
317    }
318
319    /// The lifecycle phase of one toast, or `None` when it is not in the tree.
320    pub fn phase(&self, ident: &str) -> Option<Phase> {
321        self.entry(ident).map(|entry| entry.presence.phase())
322    }
323
324    /// Adds a toast, or refreshes the one that already carries this identity.
325    pub fn push(&mut self, toast: Toast, cx: &mut Context<Self>) {
326        let theme = cx.theme().clone();
327        let remaining = toast.allowance(&theme);
328        let id = toast.ident.semantic_id();
329
330        if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id() == id) {
331            // One identity is one notification: a repeat updates what is on
332            // screen instead of publishing the same id twice.
333            entry.toast = toast;
334            entry.remaining = remaining;
335            entry.hovered = false;
336            entry.presence.show();
337            cx.notify();
338            return;
339        }
340
341        self.make_room();
342        self.entries.push(Entry::new(toast, remaining, &theme));
343        cx.notify();
344    }
345
346    /// Starts the exit of one toast. The element stays in the tree until the
347    /// exit finishes.
348    pub fn dismiss(&mut self, ident: &str, cx: &mut Context<Self>) -> bool {
349        let Some(entry) = self
350            .entries
351            .iter_mut()
352            .find(|entry| entry.id() == ident && !entry.leaving())
353        else {
354            return false;
355        };
356        entry.presence.hide();
357        cx.notify();
358        true
359    }
360
361    pub fn clear(&mut self, cx: &mut Context<Self>) {
362        for entry in &mut self.entries {
363            entry.presence.hide();
364        }
365        cx.notify();
366    }
367
368    fn entry(&self, ident: &str) -> Option<&Entry> {
369        self.entries.iter().find(|entry| entry.id() == ident)
370    }
371
372    fn set_hovered(&mut self, ident: &SharedString, hovered: bool, cx: &mut Context<Self>) {
373        if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id() == *ident) {
374            entry.hovered = hovered;
375            cx.notify();
376        }
377    }
378
379    /// Evicts oldest first, and only what is evictable. When nothing may go,
380    /// the new toast still arrives: the cap yields rather than swallow a
381    /// report that cannot be dismissed.
382    fn make_room(&mut self) {
383        while self.standing() >= self.capacity {
384            let Some(index) = self.entries.iter().position(Entry::evictable) else {
385                break;
386            };
387            self.entries[index].presence.hide();
388        }
389    }
390
391    fn standing(&self) -> usize {
392        self.entries.iter().filter(|entry| !entry.leaving()).count()
393    }
394
395    /// Advances timers and lifecycles by one frame, and drops what has gone.
396    fn tick(&mut self, window: &mut Window, cx: &mut Context<Self>) {
397        let now = cx.background_executor().now();
398        let delta = self
399            .last_tick
400            .map(|last| now.saturating_duration_since(last))
401            .unwrap_or_default();
402
403        let mut timing = false;
404        for entry in &mut self.entries {
405            timing |= entry.count_down(delta);
406        }
407        for entry in &mut self.entries {
408            entry.progress = entry.presence.animate(window, cx);
409        }
410        self.entries.retain(|entry| entry.presence.is_rendered());
411
412        self.last_tick = timing.then_some(now);
413        if timing {
414            window.request_animation_frame();
415        }
416    }
417
418    fn card(
419        &self,
420        index: usize,
421        theme: &Theme,
422        window: &mut Window,
423        cx: &mut Context<Self>,
424    ) -> AnyElement {
425        let entry = &self.entries[index];
426        let toast = &entry.toast;
427        let id = toast.ident.semantic_id();
428        let color = toast.tone.color(theme);
429        let layer = cx.entity().downgrade();
430
431        let action = toast.action.as_ref().map(|action| {
432            let handler = Rc::clone(&action.handler);
433            let layer = layer.clone();
434            let id = id.clone();
435            Button::new(toast.ident.child("action"))
436                .semantic_parent(id.clone())
437                .label(action.label.clone())
438                .secondary()
439                .small()
440                .on_click(move |window, cx| {
441                    handler(window, cx);
442                    layer
443                        .update(cx, |layer, cx| layer.dismiss(id.as_ref(), cx))
444                        .ok();
445                })
446        });
447
448        let dismiss = toast.dismissable.then(|| {
449            let dismiss_ident = toast.ident.child("dismiss");
450            let mut control = div()
451                .id(dismiss_ident.element_id())
452                .flex()
453                .flex_none()
454                .items_center()
455                .justify_center()
456                .size(px(16.0))
457                .cursor_pointer()
458                .tab_index(0)
459                .focus_ring(theme)
460                .child(
461                    icon(Icon::Close)
462                        .size(px(11.0))
463                        .text_color(theme.colors.text_faint),
464                )
465                .semantic_in(
466                    cx,
467                    NodeSpec::new(dismiss_ident.semantic_id(), Role::Button)
468                        .parent(id.clone())
469                        .text(cx.strings().text(StringKey::Dismiss)),
470                );
471            let clicked = {
472                let layer = layer.clone();
473                let id = id.clone();
474                move |_window: &mut Window, cx: &mut App| {
475                    layer
476                        .update(cx, |layer, cx| layer.dismiss(id.as_ref(), cx))
477                        .ok();
478                }
479            };
480            let keyed = clicked.clone();
481            control
482                .interactivity()
483                .on_click(move |_, window, cx| clicked(window, cx));
484            control
485                .interactivity()
486                .on_key_down(move |event, window, cx| {
487                    if matches!(event.keystroke.key.as_str(), "enter" | "space") {
488                        keyed(window, cx);
489                        cx.stop_propagation();
490                    }
491                });
492            control
493        });
494
495        let detail = toast.detail.clone().map(|detail| {
496            div()
497                .type_scale(theme, TypeScale::Caption)
498                .text_color(theme.colors.text_muted)
499                .child(detail.clone())
500                .semantic_in(
501                    cx,
502                    NodeSpec::new(toast.ident.child("detail").semantic_id(), Role::Text)
503                        .parent(id.clone())
504                        .text(detail),
505                )
506        });
507
508        let travel = TRAVEL * (1.0 - entry.progress);
509        let hovered = entry.hovered;
510        let hover_id = id.clone();
511
512        let card = surface(theme, Elevation::Overlay)
513            .id(toast.ident.element_id())
514            .row()
515            .items_start()
516            .w(px(CARD_WIDTH))
517            .gap_token(theme, Space::Sm)
518            .p_token(theme, Space::Md)
519            // The tone washes the whole surface instead of outlining it, so a
520            // toast that is a failure is a different colour rather than the
521            // same colour inside a different line.
522            .bg(color.opacity(0.12))
523            .opacity(entry.progress)
524            .top(px(if self.corner.is_top() {
525                -travel
526            } else {
527                travel
528            }))
529            .on_hover(cx.listener(move |layer, hovered: &bool, _window, cx| {
530                layer.set_hovered(&hover_id, *hovered, cx);
531            }))
532            .child(
533                div()
534                    .flex_none()
535                    .mt(px(5.0))
536                    .child(StatusDot::new(toast.tone)),
537            )
538            .child(
539                div()
540                    .column()
541                    .flex_1()
542                    .min_w_0()
543                    .gap_token(theme, Space::Xs)
544                    .child(
545                        div()
546                            .type_scale(theme, TypeScale::Label)
547                            .child(toast.message.clone()),
548                    )
549                    .children(detail),
550            )
551            .children(action)
552            .children(dismiss)
553            .semantic_in(
554                cx,
555                NodeSpec::new(id, Role::Toast)
556                    .text(toast.message.clone())
557                    .value(toast.tone.name())
558                    .live(if toast.tone == Tone::Danger {
559                        LiveRegion::Assertive
560                    } else {
561                        LiveRegion::Polite
562                    })
563                    .live_atomic(true)
564                    .hovered(hovered),
565            );
566
567        // The slot the card sits in is what slides, not the card: the card is
568        // already carrying its own arrival travel, and a flip that watched
569        // that would mistake an arrival for a reorder. The slot moves only
570        // when the stack itself reflows, which is what happens when a toast
571        // in the middle of the stack leaves.
572        let slot = flip(toast.ident.child("slot").semantic_id(), cx);
573        div().child(card).flip(&slot, window, cx).into_any_element()
574    }
575}
576
577impl Render for ToastLayer {
578    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
579        let theme = cx.theme().clone();
580        self.tick(window, cx);
581        if self.entries.is_empty() {
582            return div().into_any_element();
583        }
584
585        // The newest toast sits nearest the corner, so the column runs oldest
586        // first from a bottom corner and newest first from a top one.
587        let mut order: Vec<usize> = (0..self.entries.len()).collect();
588        if self.corner.is_top() {
589            order.reverse();
590        }
591        let mut cards: Vec<AnyElement> = Vec::with_capacity(order.len());
592        for index in order {
593            cards.push(self.card(index, &theme, window, cx));
594        }
595
596        let leading = self.corner.is_leading();
597        let top = self.corner.is_top();
598        let viewport = window.viewport_size();
599        let stack = div()
600            .column()
601            .gap_token(&theme, Space::Sm)
602            .when(leading, |element| element.items_start())
603            .when(!leading, |element| element.items_end())
604            .children(cards);
605        // The frame is painted out of its parent's layout, so it takes its
606        // size from the window rather than from wherever the host mounted it.
607        let frame = div()
608            .absolute()
609            .top_0()
610            .left_0()
611            .w(viewport.width)
612            .h(viewport.height)
613            .flex()
614            .flex_col()
615            .p_token(&theme, Space::Lg)
616            .when(top, |element| element.justify_start())
617            .when(!top, |element| element.justify_end())
618            .when(leading, |element| element.items_start())
619            .when(!leading, |element| element.items_end())
620            .child(stack);
621
622        pinned(
623            gpui::deferred(frame)
624                .priority(priority(&theme, Layer::Toast))
625                .into_any_element(),
626        )
627    }
628}
629
630/// The weak handle to the mounted layer.
631///
632/// Weak on purpose: a window that closes takes its notifications with it, and
633/// a push afterwards reports that it went nowhere instead of resurrecting a
634/// dead view.
635#[derive(Default)]
636struct ToastCenter {
637    layer: Option<WeakEntity<ToastLayer>>,
638}
639
640impl Global for ToastCenter {}
641
642/// Prepares the process for toasts. Idempotent, and called by
643/// [`crate::install`].
644pub fn install(cx: &mut App) {
645    if !cx.has_global::<ToastCenter>() {
646        cx.set_global(ToastCenter::default());
647    }
648}
649
650fn mounted(cx: &App) -> Option<Entity<ToastLayer>> {
651    cx.try_global::<ToastCenter>()?.layer.as_ref()?.upgrade()
652}
653
654/// Whether a layer is mounted and can therefore show a notification.
655pub fn is_mounted(cx: &App) -> bool {
656    mounted(cx).is_some()
657}
658
659/// Shows a toast, reporting whether it was delivered.
660///
661/// False means no layer is mounted. The library will not pick a window it was
662/// not given, so the caller learns the notification went nowhere rather than
663/// believing something was shown.
664pub fn push(cx: &mut App, toast: Toast) -> bool {
665    let Some(layer) = mounted(cx) else {
666        return false;
667    };
668    layer.update(cx, |layer, cx| layer.push(toast, cx));
669    true
670}
671
672/// Starts the exit of one toast, reporting whether it was on screen.
673pub fn dismiss(cx: &mut App, ident: &str) -> bool {
674    let Some(layer) = mounted(cx) else {
675        return false;
676    };
677    layer.update(cx, |layer, cx| layer.dismiss(ident, cx))
678}
679
680/// Starts the exit of every toast on screen.
681pub fn clear(cx: &mut App) {
682    if let Some(layer) = mounted(cx) {
683        layer.update(cx, |layer, cx| layer.clear(cx));
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    fn theme() -> Theme {
692        Theme::studio_dark()
693    }
694
695    #[test]
696    fn a_failure_stays_and_everything_else_times_out() {
697        let theme = theme();
698        for tone in [Tone::Danger, Tone::Warning] {
699            assert_eq!(
700                Toast::new("run.failed", "Refused")
701                    .tone(tone)
702                    .allowance(&theme),
703                None
704            );
705        }
706        for tone in [Tone::Success, Tone::Info, Tone::Neutral, Tone::Accent] {
707            assert_eq!(
708                Toast::new("run.saved", "Saved")
709                    .tone(tone)
710                    .allowance(&theme),
711                Some(Duration::from_millis(theme.motion.toast_ms))
712            );
713        }
714    }
715
716    #[test]
717    fn an_explicit_lifetime_outranks_the_tone() {
718        let theme = theme();
719        assert_eq!(
720            Toast::new("run.failed", "Refused")
721                .tone(Tone::Danger)
722                .timeout(Duration::from_millis(200))
723                .allowance(&theme),
724            Some(Duration::from_millis(200))
725        );
726        assert_eq!(
727            Toast::new("run.saved", "Saved")
728                .tone(Tone::Success)
729                .persistent()
730                .allowance(&theme),
731            None
732        );
733    }
734
735    #[test]
736    fn only_a_dismissable_toast_that_times_out_can_be_evicted() {
737        let theme = theme();
738        let entry = |toast: Toast| {
739            let remaining = toast.allowance(&theme);
740            Entry::new(toast, remaining, &theme)
741        };
742        assert!(entry(Toast::new("a", "Saved").tone(Tone::Success)).evictable());
743        assert!(!entry(Toast::new("b", "Refused").tone(Tone::Danger)).evictable());
744        assert!(
745            !entry(
746                Toast::new("c", "Saved")
747                    .tone(Tone::Success)
748                    .dismissable(false)
749            )
750            .evictable()
751        );
752    }
753
754    #[test]
755    fn a_hovered_toast_spends_none_of_its_allowance() {
756        let theme = theme();
757        let mut entry = Entry::new(
758            Toast::new("a", "Saved").timeout(Duration::from_millis(300)),
759            Some(Duration::from_millis(300)),
760            &theme,
761        );
762        entry.hovered = true;
763        assert!(!entry.count_down(Duration::from_millis(500)));
764        assert_eq!(entry.remaining, Some(Duration::from_millis(300)));
765
766        entry.hovered = false;
767        assert!(entry.count_down(Duration::from_millis(100)));
768        assert_eq!(entry.remaining, Some(Duration::from_millis(200)));
769    }
770
771    #[test]
772    fn a_spent_allowance_starts_the_exit_rather_than_dropping_the_toast() {
773        let theme = theme();
774        let mut entry = Entry::new(
775            Toast::new("a", "Saved"),
776            Some(Duration::from_millis(100)),
777            &theme,
778        );
779        entry.presence.settle();
780        assert!(!entry.count_down(Duration::from_millis(100)));
781        assert_eq!(entry.presence.phase(), Phase::Exiting);
782        assert!(entry.presence.is_rendered());
783    }
784
785    #[test]
786    fn the_stack_grows_from_the_corner_it_is_given() {
787        assert!(!ToastCorner::default().is_top());
788        assert!(!ToastCorner::default().is_leading());
789        assert!(ToastCorner::TopLeft.is_top() && ToastCorner::TopLeft.is_leading());
790    }
791}