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