1use crate::TestSupportExt as _;
2#[cfg(not(target_family = "wasm"))]
3use std::time::Instant;
4use std::{cell::RefCell, collections::VecDeque, rc::Rc, time::Duration};
5#[cfg(target_family = "wasm")]
6use web_time::Instant;
7
8use gpui::{
9 Anchor, AnyElement, App, Div, ElementId, FocusHandle, InteractiveElement, Interactivity,
10 IntoElement, MouseMoveEvent, ParentElement, Pixels, RenderOnce, Role, Stateful,
11 StatefulInteractiveElement, StyleRefinement, Styled, Window, canvas, div,
12 prelude::FluentBuilder as _, px,
13};
14
15use crate::{
16 ElementExt as _, StyledExt as _,
17 motion::{Spring, spring},
18};
19
20#[derive(Clone, Copy, Debug)]
22pub struct ToastMotion {
23 pub duration: Duration,
32 pub exit_duration: Duration,
34 pub collapsed_peek: Pixels,
36 pub expanded_gap: Pixels,
38 pub collapsed_scale_step: f32,
40 pub collapsed_visible: usize,
42}
43
44impl ToastMotion {
45 pub fn sonner() -> Self {
47 Self {
48 duration: Duration::from_millis(400),
49 exit_duration: Duration::from_millis(200),
50 collapsed_peek: px(14.),
51 expanded_gap: px(14.),
52 collapsed_scale_step: 0.05,
53 collapsed_visible: 3,
54 }
55 }
56}
57
58impl Default for ToastMotion {
59 fn default() -> Self {
60 Self::sonner()
61 }
62}
63
64#[derive(Clone, Debug, Default)]
66pub struct ToastStackState {
67 heights: Rc<RefCell<std::collections::HashMap<ElementId, Pixels>>>,
68 width: Rc<std::cell::Cell<Pixels>>,
69 hovered: Rc<std::cell::Cell<bool>>,
70 focused: Rc<std::cell::Cell<bool>>,
71}
72
73impl ToastStackState {
74 pub fn is_expanded(&self) -> bool {
76 self.hovered.get() || self.focused.get()
77 }
78}
79
80#[derive(Clone, Copy, Debug, Default)]
82pub struct ToastOptions {
83 pub timeout: Option<Duration>,
85}
86
87#[derive(Debug)]
88struct ManagedToast<I, T> {
89 id: I,
90 value: T,
91 status: ToastTransitionStatus,
92 timeout_remaining: Option<Duration>,
93 transition_elapsed: Duration,
94 last_advance: Instant,
95}
96
97#[derive(Debug)]
99pub struct ToastAdvance<I, T> {
100 pub changed: bool,
102 pub presented: Vec<I>,
104 pub ending: Vec<I>,
106 pub removed: Vec<(I, T)>,
108}
109
110#[derive(Debug)]
112pub struct ToastManager<I, T> {
113 entries: VecDeque<ManagedToast<I, T>>,
114 transition_duration: Duration,
115 exit_duration: Duration,
116}
117
118impl<I, T> ToastManager<I, T> {
119 pub fn new(motion: ToastMotion) -> Self {
121 Self {
122 entries: VecDeque::new(),
123 transition_duration: motion.duration,
124 exit_duration: motion.exit_duration,
125 }
126 }
127
128 pub fn len(&self) -> usize {
130 self.entries.len()
131 }
132
133 pub fn is_empty(&self) -> bool {
135 self.entries.is_empty()
136 }
137
138 pub fn iter(&self) -> impl DoubleEndedIterator<Item = (&I, &T, ToastTransitionStatus)> {
140 self.entries
141 .iter()
142 .map(|entry| (&entry.id, &entry.value, entry.status))
143 }
144
145 pub fn visible(&self, limit: usize) -> impl Iterator<Item = (&I, &T, ToastTransitionStatus)> {
147 let first = self
148 .entries
149 .iter()
150 .filter(|entry| entry.status != ToastTransitionStatus::Ending)
151 .count()
152 .saturating_sub(limit);
153 let mut active_index = 0usize;
154 self.entries.iter().filter_map(move |entry| {
155 let visible = if entry.status == ToastTransitionStatus::Ending {
156 true
157 } else {
158 let keep = active_index >= first;
159 active_index += 1;
160 keep
161 };
162 visible.then_some((&entry.id, &entry.value, entry.status))
163 })
164 }
165
166 pub fn get(&self, id: &I) -> Option<&T>
168 where
169 I: Eq,
170 {
171 self.entries
172 .iter()
173 .find_map(|entry| (&entry.id == id).then_some(&entry.value))
174 }
175}
176
177impl<I: Clone + Eq, T> ToastManager<I, T> {
178 pub fn push(&mut self, id: I, value: T, options: ToastOptions, now: Instant) -> Option<T> {
180 let replaced = self
181 .entries
182 .iter()
183 .position(|entry| entry.id == id)
184 .and_then(|index| self.entries.remove(index))
185 .map(|entry| entry.value);
186 self.entries.push_back(ManagedToast {
187 id,
188 value,
189 status: ToastTransitionStatus::Starting,
190 timeout_remaining: options.timeout,
191 transition_elapsed: Duration::ZERO,
192 last_advance: now,
193 });
194 replaced
195 }
196
197 pub fn dismiss(&mut self, id: &I, now: Instant) -> bool {
199 let Some(entry) = self.entries.iter_mut().find(|entry| &entry.id == id) else {
200 return false;
201 };
202 if entry.status == ToastTransitionStatus::Ending {
203 return false;
204 }
205 entry.status = ToastTransitionStatus::Ending;
206 entry.transition_elapsed = Duration::ZERO;
207 entry.last_advance = now;
208 true
209 }
210
211 pub fn dismiss_all(&mut self, now: Instant) -> Vec<I> {
213 let mut changed = Vec::new();
214 for entry in &mut self.entries {
215 if entry.status != ToastTransitionStatus::Ending {
216 entry.status = ToastTransitionStatus::Ending;
217 entry.transition_elapsed = Duration::ZERO;
218 entry.last_advance = now;
219 changed.push(entry.id.clone());
220 }
221 }
222 changed
223 }
224
225 pub fn advance(&mut self, now: Instant, paused: bool) -> ToastAdvance<I, T> {
227 let mut ending = Vec::new();
228 let mut presented = Vec::new();
229 let mut changed = false;
230 for entry in &mut self.entries {
231 let delta = now.saturating_duration_since(entry.last_advance);
232 entry.last_advance = now;
233 match entry.status {
234 ToastTransitionStatus::Starting => {
235 entry.transition_elapsed += delta;
236 if entry.transition_elapsed >= self.transition_duration {
237 entry.status = ToastTransitionStatus::Present;
238 entry.transition_elapsed = Duration::ZERO;
239 presented.push(entry.id.clone());
240 changed = true;
241 }
242 }
243 ToastTransitionStatus::Present if !paused => {
244 if let Some(remaining) = &mut entry.timeout_remaining {
245 *remaining = remaining.saturating_sub(delta);
246 if remaining.is_zero() {
247 entry.status = ToastTransitionStatus::Ending;
248 entry.transition_elapsed = Duration::ZERO;
249 ending.push(entry.id.clone());
250 changed = true;
251 }
252 }
253 }
254 ToastTransitionStatus::Ending => entry.transition_elapsed += delta,
255 ToastTransitionStatus::Present => {}
256 }
257 }
258 let mut removed = Vec::new();
259 let mut index = 0;
260 while index < self.entries.len() {
261 if self.entries[index].status == ToastTransitionStatus::Ending
262 && self.entries[index].transition_elapsed >= self.exit_duration
263 {
264 let entry = self.entries.remove(index).expect("toast index is valid");
265 removed.push((entry.id, entry.value));
266 changed = true;
267 } else {
268 index += 1;
269 }
270 }
271 ToastAdvance {
272 changed,
273 presented,
274 ending,
275 removed,
276 }
277 }
278}
279
280#[derive(IntoElement)]
282pub struct ToastStack {
283 id: ElementId,
284 base: crate::ObservedElement<Stateful<Div>>,
285 style: StyleRefinement,
286 state: ToastStackState,
287 motion: ToastMotion,
288 placement: Anchor,
289 focus_handle: Option<FocusHandle>,
290 children: Vec<(ElementId, AnyElement)>,
291}
292
293impl ToastStack {
294 pub fn new(id: impl Into<ElementId>, state: ToastStackState) -> Self {
296 let id = id.into();
297 Self {
298 base: div().id(id.clone()).test_support(),
299 id,
300 style: StyleRefinement::default(),
301 state,
302 motion: ToastMotion::sonner(),
303 placement: Anchor::TopRight,
304 focus_handle: None,
305 children: Vec::new(),
306 }
307 }
308
309 pub fn item(mut self, id: impl Into<ElementId>, child: impl IntoElement) -> Self {
311 self.children.push((id.into(), child.into_any_element()));
312 self
313 }
314
315 pub fn motion(mut self, motion: ToastMotion) -> Self {
317 self.motion = motion;
318 self
319 }
320
321 pub fn placement(mut self, placement: Anchor) -> Self {
323 self.placement = placement;
324 self
325 }
326
327 pub fn focus_handle(mut self, focus_handle: FocusHandle) -> Self {
329 self.focus_handle = Some(focus_handle);
330 self
331 }
332}
333
334impl Styled for ToastStack {
335 fn style(&mut self) -> &mut StyleRefinement {
336 &mut self.style
337 }
338}
339
340impl ParentElement for ToastStack {
341 fn extend(&mut self, children: impl IntoIterator<Item = AnyElement>) {
342 self.children
343 .extend(children.into_iter().enumerate().map(|(index, child)| {
344 (
345 ElementId::NamedInteger("toast-stack-child".into(), index as u64),
346 child,
347 )
348 }));
349 }
350}
351
352impl InteractiveElement for ToastStack {
353 fn interactivity(&mut self) -> &mut Interactivity {
354 self.base.interactivity()
355 }
356
357 fn track_focus(mut self, handle: &gpui::FocusHandle) -> Self {
358 self.base = self.base.track_focus(handle);
359 self
360 }
361}
362
363impl StatefulInteractiveElement for ToastStack {}
364
365fn stack_geometry(
366 heights: &[Pixels],
367 gap: Pixels,
368 peek: Pixels,
369 anchored_bottom: bool,
370) -> (Pixels, Pixels, Vec<(Pixels, Pixels)>) {
371 let count = heights.len();
372 let expanded_height = heights
373 .iter()
374 .copied()
375 .fold(px(0.), |sum, height| sum + height)
376 + gap * count.saturating_sub(1) as f32;
377 let front_height = heights.last().copied().unwrap_or(px(0.));
378 let collapsed_height = heights
379 .iter()
380 .enumerate()
381 .map(|(index, height)| {
382 let rank = count - 1 - index;
383 *height + peek * rank as f32
384 })
385 .fold(
386 front_height + peek * count.saturating_sub(1) as f32,
387 Pixels::max,
388 );
389 let offsets = heights
390 .iter()
391 .enumerate()
392 .map(|(index, height)| {
393 let rank = count - 1 - index;
394 let newer_height = heights[(index + 1)..]
395 .iter()
396 .copied()
397 .fold(px(0.), |sum, height| sum + height);
398 let expanded = if anchored_bottom {
399 expanded_height - newer_height - gap * rank as f32 - *height
400 } else {
401 newer_height + gap * rank as f32
402 };
403 let collapsed = if anchored_bottom {
404 collapsed_height - *height - peek * rank as f32
405 } else {
406 peek * rank as f32
407 };
408 (collapsed, expanded)
409 })
410 .collect();
411 (collapsed_height, expanded_height, offsets)
412}
413
414impl RenderOnce for ToastStack {
415 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
416 let focused = self
417 .focus_handle
418 .as_ref()
419 .is_some_and(|handle| handle.contains_focused(window, cx));
420 self.state.focused.set(focused);
421 let expanded = self.state.is_expanded();
422 let keys = self
423 .children
424 .iter()
425 .map(|(id, _)| id.clone())
426 .collect::<Vec<_>>();
427 self.state
428 .heights
429 .borrow_mut()
430 .retain(|id, _| keys.contains(id));
431 let measured_by_id = self.state.heights.borrow().clone();
432 let heights = keys
433 .iter()
434 .map(|id| measured_by_id.get(id).copied().unwrap_or(px(0.)))
435 .collect::<Vec<_>>();
436 let measured = self.state.heights.clone();
437 let peek = self.motion.collapsed_peek;
438 let gap = self.motion.expanded_gap;
439 let scale_step = self.motion.collapsed_scale_step;
440 let collapsed_visible = self.motion.collapsed_visible.max(1);
441 let stack_width = self.state.width.get();
442 let count = self.children.len();
443 let anchored_bottom = matches!(
444 self.placement,
445 Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight
446 );
447 let (collapsed_height, expanded_height, offsets) = stack_geometry(
448 &heights[..count.min(heights.len())],
449 gap,
450 peek,
451 anchored_bottom,
452 );
453 let geometry = Spring::new(self.motion.duration).with_epsilon(0.1);
468 let fade = Spring::new(self.motion.duration);
469 let stack_height = spring(
470 (self.id.clone(), "height"),
471 if expanded {
472 expanded_height
473 } else {
474 collapsed_height
475 },
476 geometry,
477 window,
478 cx,
479 );
480 let items = self
481 .children
482 .into_iter()
483 .enumerate()
484 .map(move |(index, (item_id, child))| {
485 let (collapsed_offset, expanded_offset) =
486 offsets.get(index).copied().unwrap_or((px(0.), px(0.)));
487 let measured = measured.clone();
488 let measured_id = item_id.clone();
489 let rank = count.saturating_sub(1 + index);
490 let target_offset = if expanded {
491 expanded_offset
492 } else {
493 collapsed_offset
494 };
495 let offset = spring(
496 (item_id.clone(), "offset"),
497 target_offset,
498 geometry,
499 window,
500 cx,
501 );
502 let target_inset = if expanded {
503 px(0.)
504 } else {
505 stack_width * (scale_step * rank.min(collapsed_visible - 1) as f32 / 2.)
506 };
507 let inset = spring(
508 (item_id.clone(), "inset"),
509 target_inset,
510 geometry,
511 window,
512 cx,
513 );
514 let opacity = spring(
515 (item_id.clone(), "visibility"),
516 if expanded || rank < collapsed_visible {
517 1.
518 } else {
519 0.
520 },
521 fade,
522 window,
523 cx,
524 );
525 div()
526 .id(item_id.clone())
527 .absolute()
528 .top_0()
529 .left_0()
530 .right_0()
531 .top(offset)
532 .left(inset)
533 .right(inset)
534 .opacity(opacity)
535 .when(!expanded && rank >= collapsed_visible, |this| {
536 this.invisible()
537 })
538 .on_prepaint(move |bounds, _, cx| {
539 let mut heights = measured.borrow_mut();
540 if heights.get(&measured_id).copied() != Some(bounds.size.height) {
541 heights.insert(measured_id.clone(), bounds.size.height);
542 cx.refresh_windows();
543 }
544 })
545 .child(child)
546 });
547
548 let hovered_state = self.state.hovered.clone();
549 let measured_width = self.state.width.clone();
550 self.base
551 .relative()
552 .h(stack_height)
553 .when_some(self.focus_handle, |this, handle| this.track_focus(&handle))
554 .child(
555 canvas(
556 |_, _, _| {},
557 move |mut bounds, _, window, cx| {
558 if measured_width.replace(bounds.size.width) != bounds.size.width {
559 cx.refresh_windows();
560 }
561 if !expanded {
562 if anchored_bottom {
563 bounds.origin.y += stack_height - collapsed_height;
564 }
565 bounds.size.height = collapsed_height;
566 }
567 let hovered_state = hovered_state.clone();
568 window.on_mouse_event(move |event: &MouseMoveEvent, _, _, cx| {
569 let hovered = bounds.contains(&event.position);
570 if hovered_state.replace(hovered) != hovered {
571 cx.refresh_windows();
572 }
573 });
574 },
575 )
576 .absolute()
577 .size_full(),
578 )
579 .children(items)
580 .refine_style(&self.style)
581 }
582}
583
584#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
586pub enum ToastTransitionStatus {
587 #[default]
589 Starting,
590 Present,
592 Ending,
594}
595
596#[derive(IntoElement)]
598pub struct Toast {
599 base: crate::ObservedElement<Stateful<Div>>,
600 style: StyleRefinement,
601 transition_status: ToastTransitionStatus,
602 children: Vec<AnyElement>,
603}
604
605impl Toast {
606 pub fn new(id: impl Into<ElementId>) -> Self {
608 Self {
609 base: div().id(id).test_support(),
610 style: StyleRefinement::default(),
611 transition_status: ToastTransitionStatus::Starting,
612 children: Vec::new(),
613 }
614 }
615
616 pub fn transition_status(mut self, status: ToastTransitionStatus) -> Self {
618 self.transition_status = status;
619 self
620 }
621
622 pub fn status(&self) -> ToastTransitionStatus {
624 self.transition_status
625 }
626}
627
628impl Styled for Toast {
629 fn style(&mut self) -> &mut StyleRefinement {
630 &mut self.style
631 }
632}
633
634impl ParentElement for Toast {
635 fn extend(&mut self, children: impl IntoIterator<Item = AnyElement>) {
636 self.children.extend(children);
637 }
638}
639
640impl InteractiveElement for Toast {
641 fn interactivity(&mut self) -> &mut Interactivity {
642 self.base.interactivity()
643 }
644
645 fn track_focus(mut self, handle: &gpui::FocusHandle) -> Self {
646 self.base = self.base.track_focus(handle);
647 self
648 }
649}
650
651impl StatefulInteractiveElement for Toast {}
652
653impl RenderOnce for Toast {
654 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
655 self.base
656 .role(Role::Alert)
657 .children(self.children)
658 .refine_style(&self.style)
659 }
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665 use gpui::{Element as _, accesskit, point};
666
667 #[test]
668 fn manager_pauses_timeout_and_removes_only_after_exit() {
669 let motion = ToastMotion::sonner();
670 let start = Instant::now();
671 let mut manager = ToastManager::new(motion);
672 manager.push(
673 "a",
674 1,
675 ToastOptions {
676 timeout: Some(Duration::from_secs(5)),
677 },
678 start,
679 );
680 manager.advance(start + motion.duration, false);
681 manager.advance(start + motion.duration + Duration::from_secs(4), true);
682 assert_eq!(
683 manager.iter().next().unwrap().2,
684 ToastTransitionStatus::Present
685 );
686 let ending = manager.advance(start + motion.duration + Duration::from_secs(9), false);
687 assert_eq!(ending.ending, vec!["a"]);
688 assert!(ending.removed.is_empty());
689 let removed = manager.advance(
690 start + motion.duration + motion.exit_duration + Duration::from_secs(9),
691 false,
692 );
693 assert_eq!(removed.removed, vec![("a", 1)]);
694 }
695
696 #[test]
697 fn manager_limit_keeps_ending_toasts_mounted() {
698 let now = Instant::now();
699 let mut manager = ToastManager::new(ToastMotion::sonner());
700 for id in ["a", "b", "c"] {
701 manager.push(id, id, ToastOptions::default(), now);
702 }
703 manager.dismiss(&"a", now);
704 assert_eq!(
705 manager.visible(1).map(|(id, _, _)| *id).collect::<Vec<_>>(),
706 vec!["a", "c"]
707 );
708 }
709
710 #[test]
711 fn manager_replaces_duplicate_ids_as_the_newest_toast() {
712 let now = Instant::now();
713 let mut manager = ToastManager::new(ToastMotion::sonner());
714 manager.push("a", 1, ToastOptions::default(), now);
715 manager.push("b", 2, ToastOptions::default(), now);
716 assert_eq!(manager.push("a", 3, ToastOptions::default(), now), Some(1));
717 assert_eq!(
718 manager
719 .iter()
720 .map(|(id, value, _)| (*id, *value))
721 .collect::<Vec<_>>(),
722 vec![("b", 2), ("a", 3)]
723 );
724 }
725
726 #[test]
727 fn manager_resets_clock_when_reused_after_becoming_empty() {
728 let motion = ToastMotion::sonner();
729 let start = Instant::now();
730 let mut manager = ToastManager::new(motion);
731 manager.push("old", 1, ToastOptions::default(), start);
732 manager.dismiss(&"old", start);
733 manager.advance(start + motion.exit_duration, false);
734 let later = start + Duration::from_secs(60);
735 manager.push("new", 2, ToastOptions::default(), later);
736 manager.advance(later + Duration::from_millis(50), false);
737 assert_eq!(
738 manager.iter().next().unwrap().2,
739 ToastTransitionStatus::Starting
740 );
741 }
742
743 #[test]
744 fn newly_pushed_toast_does_not_inherit_existing_entry_clock() {
745 let motion = ToastMotion::sonner();
746 let start = Instant::now();
747 let mut manager = ToastManager::new(motion);
748 manager.push("old", 1, ToastOptions::default(), start);
749 let later = start + Duration::from_secs(60);
750 manager.push("new", 2, ToastOptions::default(), later);
751 manager.advance(later + Duration::from_millis(50), false);
752 assert_eq!(
753 manager
754 .iter()
755 .find(|(id, _, _)| **id == "new")
756 .map(|(_, _, status)| status),
757 Some(ToastTransitionStatus::Starting)
758 );
759 }
760
761 #[test]
762 fn stack_geometry_anchors_newest_item_and_supports_variable_heights() {
763 let heights = [px(40.), px(60.), px(80.)];
764 let (collapsed, expanded, top) = stack_geometry(&heights, px(12.), px(12.), false);
765 assert_eq!(collapsed, px(104.));
766 assert_eq!(expanded, px(204.));
767 assert_eq!(
768 top,
769 vec![(px(24.), px(164.)), (px(12.), px(92.)), (px(0.), px(0.))]
770 );
771
772 let (_, _, bottom) = stack_geometry(&heights, px(12.), px(12.), true);
773 assert_eq!(
774 bottom,
775 vec![(px(40.), px(0.)), (px(32.), px(52.)), (px(24.), px(124.))]
776 );
777
778 let tall_behind = [px(180.), px(60.)];
779 let (collapsed, _, top) = stack_geometry(&tall_behind, px(14.), px(14.), false);
780 assert_eq!(collapsed, px(194.));
781 assert_eq!(top[0].0, px(14.));
782 assert!(top[0].0 + tall_behind[0] <= collapsed);
783 assert_eq!(top[0].0 - top[1].0, px(14.));
784
785 let (_, _, bottom) = stack_geometry(&tall_behind, px(14.), px(14.), true);
786 assert!(bottom[0].0 >= px(0.));
787 assert!(bottom[0].0 + tall_behind[0] <= collapsed);
788 assert_eq!(
789 (bottom[1].0 + tall_behind[1]) - (bottom[0].0 + tall_behind[0]),
790 px(14.)
791 );
792 }
793
794 #[gpui::test]
795 fn toast_exposes_alert_semantics(cx: &mut gpui::TestAppContext) {
796 let window = cx.add_empty_window();
797 window.update(|window, cx| {
798 let mut node = accesskit::Node::new(Role::Alert);
799 Toast::new("toast")
800 .render(window, cx)
801 .into_element()
802 .write_a11y_info(&mut node);
803 assert_eq!(node.role(), Role::Alert);
804 });
805 }
806
807 #[gpui::test]
808 fn stack_bootstraps_measurement_without_zero_height_clipping(cx: &mut gpui::TestAppContext) {
809 struct Harness {
810 state: ToastStackState,
811 }
812 impl gpui::Render for Harness {
813 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
814 ToastStack::new("stack", self.state.clone())
815 .w(px(300.))
816 .item("first", div().h(px(80.)).child("Visible"))
817 }
818 }
819
820 let (view, cx) = cx.add_window_view(|_, _| Harness {
821 state: ToastStackState::default(),
822 });
823 cx.update(|window, cx| window.draw(cx).clear(cx));
824 view.read_with(cx, |view, _| {
825 assert_eq!(
826 view.state
827 .heights
828 .borrow()
829 .get(&ElementId::from("first"))
830 .copied(),
831 Some(px(80.))
832 );
833 });
834 }
835
836 #[gpui::test]
837 fn stack_expands_for_hover_and_focus(cx: &mut gpui::TestAppContext) {
838 struct Harness {
839 state: ToastStackState,
840 focus: FocusHandle,
841 }
842 impl gpui::Render for Harness {
843 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
844 ToastStack::new("stack", self.state.clone())
845 .focus_handle(self.focus.clone())
846 .w(px(300.))
847 .item("first", div().h(px(80.)))
848 }
849 }
850
851 let (view, cx) = cx.add_window_view(|_, cx| Harness {
852 state: ToastStackState::default(),
853 focus: cx.focus_handle().tab_stop(true),
854 });
855 cx.update(|window, cx| window.draw(cx).clear(cx));
856
857 cx.simulate_mouse_move(point(px(10.), px(10.)), None, gpui::Modifiers::default());
858 cx.update(|window, cx| window.draw(cx).clear(cx));
859 view.read_with(cx, |view, _| assert!(view.state.is_expanded()));
860
861 cx.simulate_mouse_move(point(px(400.), px(400.)), None, gpui::Modifiers::default());
862 cx.update(|window, cx| window.draw(cx).clear(cx));
863 view.read_with(cx, |view, _| assert!(!view.state.is_expanded()));
864
865 cx.simulate_mouse_move(point(px(10.), px(10.)), None, gpui::Modifiers::default());
866 cx.update(|window, cx| window.draw(cx).clear(cx));
867 view.read_with(cx, |view, _| assert!(view.state.is_expanded()));
868
869 cx.simulate_mouse_move(point(px(400.), px(400.)), None, gpui::Modifiers::default());
870 cx.update(|window, cx| window.draw(cx).clear(cx));
871 view.read_with(cx, |view, _| assert!(!view.state.is_expanded()));
872
873 let focus = view.read_with(cx, |view, _| view.focus.clone());
874 cx.update(|window, cx| {
875 focus.focus(window, cx);
876 window.draw(cx).clear(cx);
877 });
878 view.read_with(cx, |view, _| assert!(view.state.is_expanded()));
879 }
880
881 #[gpui::test]
882 fn keyed_stack_reflow_moves_from_the_current_visual_position(cx: &mut gpui::TestAppContext) {
883 struct Harness {
884 state: ToastStackState,
885 show_second: bool,
886 }
887 impl gpui::Render for Harness {
888 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
889 ToastStack::new("stack", self.state.clone())
890 .w(px(300.))
891 .item(
892 "first",
893 div().debug_selector(|| "first-toast".into()).h(px(40.)),
894 )
895 .when(self.show_second, |stack| {
896 stack.item(
897 "second",
898 div().debug_selector(|| "second-toast".into()).h(px(80.)),
899 )
900 })
901 }
902 }
903
904 let (view, cx) = cx.add_window_view(|_, _| Harness {
905 state: ToastStackState::default(),
906 show_second: false,
907 });
908 cx.update(|window, cx| {
909 window.draw(cx).clear(cx);
910 window.draw(cx).clear(cx);
911 });
912 let initial_y = cx.debug_bounds("first-toast").unwrap().origin.y;
913
914 view.update(cx, |view, cx| {
915 view.show_second = true;
916 cx.notify();
917 });
918 cx.run_until_parked();
919 let first_reflow_y = cx.debug_bounds("first-toast").unwrap().origin.y;
920 assert_eq!(first_reflow_y, initial_y);
921
922 cx.executor().advance_clock(Duration::from_millis(200));
923 cx.update(|window, cx| window.draw(cx).clear(cx));
924 let middle_y = cx.debug_bounds("first-toast").unwrap().origin.y;
925 assert!(middle_y > initial_y);
926 assert!(middle_y < initial_y + px(14.));
927
928 cx.executor().advance_clock(Duration::from_millis(200));
929 cx.update(|window, cx| window.draw(cx).clear(cx));
930 assert_eq!(
931 cx.debug_bounds("first-toast").unwrap().origin.y,
932 initial_y + px(14.)
933 );
934 assert!(
935 cx.debug_bounds("first-toast").unwrap().size.width
936 < cx.debug_bounds("second-toast").unwrap().size.width
937 );
938 }
939
940 #[gpui::test]
941 fn bottom_stack_reflow_moves_up_without_an_opposite_direction_jump(
942 cx: &mut gpui::TestAppContext,
943 ) {
944 struct Harness {
945 state: ToastStackState,
946 show_second: bool,
947 }
948 impl gpui::Render for Harness {
949 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
950 div().relative().size(px(300.)).child(
951 ToastStack::new("bottom-stack", self.state.clone())
952 .placement(Anchor::BottomRight)
953 .absolute()
954 .bottom_0()
955 .w(px(300.))
956 .item(
957 "bottom-first",
958 div()
959 .debug_selector(|| "bottom-first-toast".into())
960 .h(px(40.)),
961 )
962 .when(self.show_second, |stack| {
963 stack.item("bottom-second", div().h(px(80.)))
964 }),
965 )
966 }
967 }
968
969 let (view, cx) = cx.add_window_view(|_, _| Harness {
970 state: ToastStackState::default(),
971 show_second: false,
972 });
973 cx.update(|window, cx| {
974 window.draw(cx).clear(cx);
975 window.draw(cx).clear(cx);
976 });
977 cx.executor().advance_clock(Duration::from_secs(1));
983 cx.update(|window, cx| window.draw(cx).clear(cx));
984 let initial_y = cx.debug_bounds("bottom-first-toast").unwrap().origin.y;
985
986 view.update(cx, |view, cx| {
987 view.show_second = true;
988 cx.notify();
989 });
990 cx.run_until_parked();
991 assert_eq!(
992 cx.debug_bounds("bottom-first-toast").unwrap().origin.y,
993 initial_y
994 );
995
996 cx.executor().advance_clock(Duration::from_millis(200));
997 cx.update(|window, cx| window.draw(cx).clear(cx));
998 let middle_y = cx.debug_bounds("bottom-first-toast").unwrap().origin.y;
999 assert!(middle_y < initial_y);
1000 assert!(
1001 middle_y >= initial_y - px(14.),
1002 "middle={middle_y:?}, initial={initial_y:?}"
1003 );
1004
1005 cx.executor().advance_clock(Duration::from_secs(1));
1006 cx.update(|window, cx| window.draw(cx).clear(cx));
1007 assert_eq!(
1008 cx.debug_bounds("bottom-first-toast").unwrap().origin.y,
1009 initial_y - px(14.)
1010 );
1011 }
1012}