1use std::cell::RefCell;
11use std::collections::{HashMap, HashSet};
12use std::future::Future;
13use std::rc::Rc;
14use std::time::Duration;
15
16use gpui::{
17 prelude::*, px, AnimationExt, AnyElement, App, ElementId, Entity, Global, IntoElement,
18 Keystroke, Pixels, RenderOnce, SharedString, Styled, Subscription, Window,
19};
20use herogpui_core::{element_id, Color};
21use herogpui_theme::ActiveTheme;
22
23use crate::a11y::{self, A11y as _};
24use crate::icons;
25
26pub const DEFAULT_MAX_VISIBLE_TOASTS: usize = 3;
28
29pub const DEFAULT_TOAST_EXIT_DURATION: Duration = Duration::from_millis(300);
33
34pub const DEFAULT_TOAST_HOTKEY: ToastHotkey = ToastHotkey::ALT_T;
36
37#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct ToastHotkey {
43 pub alt: bool,
45 pub shift: bool,
47 pub control: bool,
49 pub platform: bool,
51 pub key: SharedString,
53}
54
55impl ToastHotkey {
56 pub const ALT_T: Self = Self {
58 alt: true,
59 shift: false,
60 control: false,
61 platform: false,
62 key: SharedString::new_static("t"),
63 };
64
65 pub fn disabled() -> Self {
67 Self {
68 alt: false,
69 shift: false,
70 control: false,
71 platform: false,
72 key: SharedString::default(),
73 }
74 }
75
76 fn matches(&self, keystroke: &Keystroke) -> bool {
77 if self.key.is_empty() {
78 return false;
79 }
80 keystroke.modifiers.alt == self.alt
81 && keystroke.modifiers.shift == self.shift
82 && keystroke.modifiers.control == self.control
83 && keystroke.modifiers.platform == self.platform
84 && keystroke.key.eq_ignore_ascii_case(self.key.as_ref())
85 }
86}
87
88#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90pub enum ToastPlacement {
91 TopStart,
93 Top,
95 TopEnd,
97 BottomStart,
99 #[default]
101 Bottom,
102 BottomEnd,
104}
105
106impl ToastPlacement {
107 pub const ALL: [ToastPlacement; 6] = [
109 ToastPlacement::TopStart,
110 ToastPlacement::Top,
111 ToastPlacement::TopEnd,
112 ToastPlacement::BottomStart,
113 ToastPlacement::Bottom,
114 ToastPlacement::BottomEnd,
115 ];
116
117 fn is_top(self) -> bool {
118 matches!(self, Self::TopStart | Self::Top | Self::TopEnd)
119 }
120
121 pub fn label(self) -> &'static str {
123 match self {
124 ToastPlacement::TopStart => "Top start",
125 ToastPlacement::Top => "Top",
126 ToastPlacement::TopEnd => "Top end",
127 ToastPlacement::BottomStart => "Bottom start",
128 ToastPlacement::Bottom => "Bottom",
129 ToastPlacement::BottomEnd => "Bottom end",
130 }
131 }
132}
133
134pub type ToastHandler = std::sync::Arc<dyn Fn(&mut App) + 'static>;
136
137fn toast_entering<E>(
141 el: E,
142 id: impl Into<ElementId>,
143 edge: crate::anim::Edge,
144 travel: Pixels,
145 motion: crate::anim::Motion,
146 cx: &App,
147) -> AnyElement
148where
149 E: IntoElement + Styled + 'static,
150{
151 if ActiveTheme::reduce_motion(cx) {
152 return el.into_any_element();
153 }
154 let id = id.into();
155 let opacity = el.with_animation(
156 element_id::scoped(&id, "opacity"),
157 gpui::Animation::new(Duration::from_millis(crate::anim::TOAST_OPACITY_MS))
158 .with_easing(move |t| motion.curve.at(t)),
159 |el, delta| el.opacity(delta),
160 );
161 gpui::div()
162 .child(opacity)
163 .with_animation(
164 element_id::scoped(&id, "slide"),
165 gpui::Animation::new(Duration::from_millis(motion.ms))
166 .with_easing(move |t| motion.curve.at(t)),
167 move |el, delta| {
168 let remaining = travel * (1.0 - delta);
169 match edge {
170 crate::anim::Edge::Left => el.ml(-remaining),
171 crate::anim::Edge::Right => el.mr(-remaining),
172 crate::anim::Edge::Top => el.mt(-remaining),
173 crate::anim::Edge::Bottom => el.mb(-remaining),
174 }
175 },
176 )
177 .into_any_element()
178}
179
180fn toast_exiting<E>(
183 el: E,
184 id: impl Into<ElementId>,
185 edge: crate::anim::Edge,
186 travel: Pixels,
187 motion: crate::anim::Motion,
188 cx: &App,
189) -> AnyElement
190where
191 E: IntoElement + Styled + 'static,
192{
193 if ActiveTheme::reduce_motion(cx) {
194 return el.into_any_element();
195 }
196 let id = id.into();
197 let opacity = el.with_animation(
198 element_id::scoped(&id, "opacity"),
199 gpui::Animation::new(Duration::from_millis(crate::anim::TOAST_OPACITY_MS))
200 .with_easing(move |t| motion.curve.at(t)),
201 |el, delta| el.opacity(1.0 - delta),
202 );
203 gpui::div()
204 .child(opacity)
205 .with_animation(
206 element_id::scoped(&id, "slide"),
207 gpui::Animation::new(Duration::from_millis(motion.ms))
208 .with_easing(move |t| motion.curve.at(t)),
209 move |el, delta| {
210 let gone = travel * delta;
211 match edge {
212 crate::anim::Edge::Left => el.ml(-gone),
213 crate::anim::Edge::Right => el.mr(-gone),
214 crate::anim::Edge::Top => el.mt(-gone),
215 crate::anim::Edge::Bottom => el.mb(-gone),
216 }
217 },
218 )
219 .into_any_element()
220}
221
222pub type ToastIndicatorContent = Rc<dyn Fn(&mut App) -> AnyElement + 'static>;
230
231#[derive(Clone)]
233pub struct ToastData {
234 pub id: u64,
236 pub color: Color,
238 pub title: SharedString,
240 pub description: Option<SharedString>,
242 pub closable: bool,
244 pub indicator: Option<SharedString>,
250 pub indicator_content: Option<ToastIndicatorContent>,
253 pub indicator_set: bool,
255 pub is_loading: bool,
257 pub action: Option<(SharedString, ToastHandler)>,
259 pub on_close: Option<ToastHandler>,
261 pub close_hover_bg: Option<gpui::Hsla>,
263 pub padding: Option<Pixels>,
266 pub radius: Option<Pixels>,
269}
270
271pub struct ToastStore {
273 toasts: Vec<ToastData>,
274 next_id: u64,
275 paused: bool,
278 interaction_paused: bool,
281 timeouts: HashMap<u64, Duration>,
283 timer_generations: HashMap<u64, u64>,
286 next_timer_generation: u64,
287 reported: HashSet<u64>,
291 exiting: Vec<ToastData>,
293 exit_generations: HashMap<u64, u64>,
294 exit_frontmost: HashSet<u64>,
295 next_exit_generation: u64,
296 exit_duration: Duration,
298 hotkey: ToastHotkey,
300 viewport_expanded: bool,
302}
303
304impl ToastStore {
305 fn new() -> Self {
306 Self {
307 toasts: Vec::new(),
308 next_id: 1,
309 paused: false,
310 interaction_paused: false,
311 timeouts: HashMap::new(),
312 timer_generations: HashMap::new(),
313 next_timer_generation: 1,
314 reported: HashSet::new(),
315 exiting: Vec::new(),
316 exit_generations: HashMap::new(),
317 exit_frontmost: HashSet::new(),
318 next_exit_generation: 1,
319 exit_duration: DEFAULT_TOAST_EXIT_DURATION,
320 hotkey: DEFAULT_TOAST_HOTKEY,
321 viewport_expanded: false,
322 }
323 }
324
325 pub fn subscribe(
331 store: &Entity<Self>,
332 cx: &mut App,
333 mut f: impl FnMut(&mut App) + 'static,
334 ) -> Subscription {
335 cx.observe(store, move |_, cx| f(cx))
336 }
337
338 pub fn toasts(&self) -> &[ToastData] {
340 &self.toasts
341 }
342
343 pub fn visible_toasts(&self, max_visible: usize) -> &[ToastData] {
346 &self.toasts[..self.toasts.len().min(max_visible.max(1))]
347 }
348
349 pub fn pause_all(&mut self) {
351 self.paused = true;
352 }
353
354 pub fn resume_all(&mut self) {
356 self.paused = false;
357 }
358
359 pub fn is_paused(&self) -> bool {
361 self.paused
362 }
363
364 pub fn timers_paused(&self) -> bool {
366 self.paused || self.interaction_paused
367 }
368
369 pub fn is_interaction_paused(&self) -> bool {
371 self.interaction_paused
372 }
373
374 pub fn exiting(&self) -> &[ToastData] {
376 &self.exiting
377 }
378
379 pub fn is_expanded(&self) -> bool {
381 self.viewport_expanded
382 }
383
384 pub fn hidden_toasts(&self, max_visible: usize) -> &[ToastData] {
386 let start = self.toasts.len().min(max_visible.max(1));
387 &self.toasts[start..]
388 }
389
390 pub fn add(store: &Entity<Self>, data: ToastData, cx: &mut App) -> u64 {
392 store.update(cx, |store, cx| {
393 let id = store.insert(data);
394 cx.notify();
395 id
396 })
397 }
398
399 pub fn close(store: &Entity<Self>, id: u64, cx: &mut App) {
406 let skip_exit = ActiveTheme::reduce_motion(cx);
407 let (on_close, exit) = store.update(cx, |store, cx| {
408 let callback = if store.claim_close(id) {
409 store.on_close(id)
410 } else {
411 None
412 };
413 let was_frontmost = store.toasts.first().is_some_and(|toast| toast.id == id);
414 let data = store.toasts.iter().find(|toast| toast.id == id).cloned();
415 store.dismiss(id);
416 let exit = if skip_exit || store.exit_duration.is_zero() {
417 None
418 } else {
419 data.and_then(|toast| store.begin_exit(toast, was_frontmost))
420 };
421 cx.notify();
422 (callback, exit)
423 });
424 if let Some(callback) = on_close {
425 callback(cx);
426 }
427 if let Some((id, generation, duration)) = exit {
428 start_exit_timer(store.downgrade(), id, generation, duration, cx);
429 }
430 }
431
432 pub fn clear(store: &Entity<Self>, cx: &mut App) {
434 store.update(cx, |store, cx| {
435 store.clear_inner();
436 cx.notify();
437 });
438 }
439
440 fn clear_inner(&mut self) {
441 self.reported
445 .extend(self.toasts.iter().map(|toast| toast.id));
446 self.toasts.clear();
447 self.timeouts.clear();
448 self.timer_generations.clear();
449 self.exiting.clear();
450 self.exit_generations.clear();
451 self.exit_frontmost.clear();
452 }
453
454 fn on_close(&self, id: u64) -> Option<ToastHandler> {
457 self.toasts
458 .iter()
459 .find(|t| t.id == id)
460 .and_then(|t| t.on_close.clone())
461 }
462
463 fn claim_close(&mut self, id: u64) -> bool {
470 self.reported.insert(id)
471 }
472
473 fn dismiss(&mut self, id: u64) {
474 self.toasts.retain(|t| t.id != id);
475 self.timeouts.remove(&id);
476 self.timer_generations.remove(&id);
477 }
478
479 fn begin_exit(
480 &mut self,
481 toast: ToastData,
482 was_frontmost: bool,
483 ) -> Option<(u64, u64, Duration)> {
484 let id = toast.id;
485 self.exiting.retain(|existing| existing.id != id);
486 self.exiting.push(toast);
487 if was_frontmost {
488 self.exit_frontmost.insert(id);
489 } else {
490 self.exit_frontmost.remove(&id);
491 }
492 let generation = self.next_exit_generation;
493 self.next_exit_generation = self.next_exit_generation.saturating_add(1);
494 self.exit_generations.insert(id, generation);
495 Some((id, generation, self.exit_duration))
496 }
497
498 fn arm_timeout(&mut self, id: u64, timeout: Duration) -> u64 {
499 self.timeouts.insert(id, timeout);
500 let generation = self.next_timer_generation;
501 self.next_timer_generation = self.next_timer_generation.saturating_add(1);
502 self.timer_generations.insert(id, generation);
503 generation
504 }
505
506 pub fn insert(&mut self, mut data: ToastData) -> u64 {
508 if data.id == 0 {
509 data.id = self.next_id;
510 self.next_id += 1;
511 } else if data.id >= self.next_id {
512 self.next_id = data.id.saturating_add(1);
513 }
514 let id = data.id;
515 self.reported.remove(&id);
519 self.toasts.insert(0, data);
520 id
521 }
522}
523
524struct ToastHub {
525 store: Entity<ToastStore>,
526}
527impl Global for ToastHub {}
528
529pub fn toast_store(cx: &mut App) -> Entity<ToastStore> {
531 if let Some(hub) = cx.try_global::<ToastHub>() {
532 return hub.store.clone();
533 }
534 let store = cx.new(|_| ToastStore::new());
535 cx.set_global(ToastHub {
536 store: store.clone(),
537 });
538 store
539}
540
541pub const DEFAULT_TOAST_TIMEOUT: Duration = Duration::from_secs(4);
544
545#[must_use = "a Toast is shown only once it is pushed (`push`, `update`)"]
547pub struct Toast {
548 color: Color,
549 title: SharedString,
550 description: Option<SharedString>,
551 closable: bool,
552 indicator: Option<SharedString>,
553 indicator_content: Option<ToastIndicatorContent>,
554 indicator_set: bool,
555 is_loading: bool,
556 action: Option<(SharedString, ToastHandler)>,
557 on_close: Option<ToastHandler>,
558 timeout: Option<Duration>,
559 timeout_set: bool,
562 close_hover_bg: Option<gpui::Hsla>,
564 padding: Option<Pixels>,
566 radius: Option<Pixels>,
568}
569
570impl Toast {
571 pub fn new(title: impl Into<SharedString>) -> Self {
573 Self {
574 color: Color::Default,
575 title: title.into(),
576 description: None,
577 closable: true,
578 indicator: None,
579 indicator_content: None,
580 indicator_set: false,
581 is_loading: false,
582 action: None,
583 on_close: None,
584 timeout: Some(DEFAULT_TOAST_TIMEOUT),
585 timeout_set: false,
586 close_hover_bg: None,
587 padding: None,
588 radius: None,
589 }
590 }
591
592 pub fn success(title: impl Into<SharedString>) -> Self {
594 Self::new(title).variant(Color::Success)
595 }
596
597 pub fn error(title: impl Into<SharedString>) -> Self {
599 Self::new(title).variant(Color::Danger)
600 }
601
602 pub fn loading(title: impl Into<SharedString>) -> Self {
605 Self::new(title).is_loading(true).timeout(Duration::ZERO)
606 }
607
608 pub fn description(mut self, d: impl Into<SharedString>) -> Self {
610 self.description = Some(d.into());
611 self
612 }
613
614 pub fn indicator(mut self, icon: impl Into<Option<SharedString>>) -> Self {
619 self.indicator = icon.into();
620 self.indicator_content = None;
621 self.indicator_set = true;
622 self
623 }
624
625 pub fn indicator_content<E, F>(mut self, render: F) -> Self
630 where
631 E: IntoElement + 'static,
632 F: Fn(&mut App) -> E + 'static,
633 {
634 self.indicator_content = Some(Rc::new(move |cx| render(cx).into_any_element()));
635 self.indicator_set = true;
636 self
637 }
638
639 pub fn is_loading(mut self, v: bool) -> Self {
642 self.is_loading = v;
643 self
644 }
645
646 pub fn action(
649 mut self,
650 label: impl Into<SharedString>,
651 on_press: impl Fn(&mut App) + 'static,
652 ) -> Self {
653 self.action = Some((label.into(), std::sync::Arc::new(on_press)));
654 self
655 }
656
657 pub fn on_close(mut self, f: impl Fn(&mut App) + 'static) -> Self {
660 self.on_close = Some(std::sync::Arc::new(f));
661 self
662 }
663
664 pub fn timeout(mut self, timeout: Duration) -> Self {
667 self.timeout = Some(timeout);
668 self.timeout_set = true;
669 self
670 }
671
672 pub fn variant(mut self, variant: Color) -> Self {
674 self.color = variant;
675 self
676 }
677
678 pub fn closable(mut self, v: bool) -> Self {
680 self.closable = v;
681 self
682 }
683
684 pub fn close_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
686 self.close_hover_bg = Some(color.into());
687 self
688 }
689
690 pub fn padding(mut self, padding: impl Into<Pixels>) -> Self {
693 self.padding = Some(padding.into());
694 self
695 }
696
697 pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
702 self.radius = Some(radius.into());
703 self
704 }
705
706 pub fn push(self, duration: Option<Duration>, cx: &mut App) -> u64 {
711 let timeout = duration.or(self.timeout).unwrap_or(Duration::ZERO);
714 let store = toast_store(cx);
715 let (id, generation) = store.update(cx, |s, cx| {
716 let id = s.next_id;
717 s.next_id += 1;
718 let pushed = s.insert(ToastData {
719 id,
720 color: self.color,
721 title: self.title.clone(),
722 description: self.description.clone(),
723 closable: self.closable,
724 indicator: self.indicator.clone(),
725 indicator_content: self.indicator_content.clone(),
726 indicator_set: self.indicator_set,
727 is_loading: self.is_loading,
728 action: self.action.clone(),
729 on_close: self.on_close.clone(),
730 close_hover_bg: self.close_hover_bg,
731 padding: self.padding,
732 radius: self.radius,
733 });
734 let generation = if timeout.is_zero() {
735 None
736 } else {
737 Some(s.arm_timeout(id, timeout))
738 };
739 cx.notify();
740 (pushed, generation)
741 });
742 if let Some(generation) = generation {
743 start_toast_timer(store.downgrade(), id, timeout, generation, cx);
744 }
745 id
746 }
747
748 pub fn update(self, id: u64, cx: &mut App) -> u64 {
755 let store = toast_store(cx);
756 let timeout = self
757 .timeout_set
758 .then_some(self.timeout.unwrap_or(Duration::ZERO));
759 let on_close_set = self.on_close.is_some();
760 let replaced = store.update(cx, |s, cx| {
761 let existing = s.toasts.iter_mut().find(|toast| toast.id == id)?;
762 existing.color = self.color;
763 existing.title = self.title.clone();
764 existing.description = self.description.clone();
765 existing.closable = self.closable;
766 existing.indicator = self.indicator.clone();
767 existing.indicator_content = self.indicator_content.clone();
768 existing.indicator_set = self.indicator_set;
769 existing.is_loading = self.is_loading;
770 existing.action = self.action.clone();
771 existing.close_hover_bg = self.close_hover_bg;
772 existing.padding = self.padding;
773 existing.radius = self.radius;
774 if on_close_set {
775 existing.on_close = self.on_close.clone();
776 }
777 let generation = match timeout {
778 None => None,
779 Some(duration) if duration.is_zero() => {
780 s.timeouts.remove(&id);
781 s.timer_generations.remove(&id);
782 None
783 }
784 Some(duration) => Some((duration, s.arm_timeout(id, duration))),
785 };
786 cx.notify();
787 Some(generation)
788 });
789 match replaced {
790 Some(Some((duration, generation))) => {
791 start_toast_timer(store.downgrade(), id, duration, generation, cx);
792 id
793 }
794 Some(None) => id,
795 None => self.push(None, cx),
796 }
797 }
798
799 pub fn promise(
806 future: impl Future<Output = Result<SharedString, SharedString>> + 'static,
807 loading: impl Into<SharedString>,
808 cx: &mut App,
809 ) -> u64 {
810 let (id, task) = Self::promise_task(future, loading, cx);
811 task.detach();
812 id
813 }
814
815 pub fn promise_task(
820 future: impl Future<Output = Result<SharedString, SharedString>> + 'static,
821 loading: impl Into<SharedString>,
822 cx: &mut App,
823 ) -> (u64, gpui::Task<()>) {
824 let id = Self::loading(loading).push(None, cx);
825 let task = cx.spawn(async move |cx: &mut gpui::AsyncApp| {
826 let result = future.await;
827 cx.update(|cx| match result {
828 Ok(title) => {
829 Self::success(title)
830 .timeout(DEFAULT_TOAST_TIMEOUT)
831 .update(id, cx);
832 }
833 Err(title) => {
834 Self::error(title)
835 .timeout(DEFAULT_TOAST_TIMEOUT)
836 .update(id, cx);
837 }
838 });
839 });
840 (id, task)
841 }
842}
843
844enum ToastTimerTick {
845 Continue,
846 Closed(Option<ToastHandler>),
847 Expired,
848}
849
850fn start_exit_timer(
851 store: gpui::WeakEntity<ToastStore>,
852 id: u64,
853 generation: u64,
854 duration: Duration,
855 cx: &mut App,
856) {
857 cx.spawn(async move |cx: &mut gpui::AsyncApp| {
858 cx.background_executor().timer(duration).await;
859 let Some(store) = store.upgrade() else { return };
860 store.update(cx, |s, cx| {
861 if s.exit_generations.get(&id) != Some(&generation) {
862 return;
863 }
864 s.exiting.retain(|toast| toast.id != id);
865 s.exit_generations.remove(&id);
866 s.exit_frontmost.remove(&id);
867 cx.notify();
868 });
869 })
870 .detach();
871}
872
873fn start_toast_timer(
874 store: gpui::WeakEntity<ToastStore>,
875 id: u64,
876 timeout: Duration,
877 generation: u64,
878 cx: &mut App,
879) {
880 cx.spawn(async move |cx: &mut gpui::AsyncApp| {
881 const TICK: Duration = Duration::from_millis(100);
884 let mut left = timeout;
885 loop {
886 cx.background_executor().timer(TICK).await;
887 let Some(store) = store.upgrade() else { return };
888 let tick = store.update(cx, |s, _cx| {
889 if !s.toasts.iter().any(|toast| toast.id == id) {
890 s.timer_generations.remove(&id);
891 s.timeouts.remove(&id);
892 return ToastTimerTick::Closed(None);
893 }
894 if s.timer_generations.get(&id) != Some(&generation) {
895 return ToastTimerTick::Closed(None);
896 }
897 if s.timers_paused() {
898 return ToastTimerTick::Continue;
899 }
900 left = left.saturating_sub(TICK);
901 if left.is_zero() {
902 return ToastTimerTick::Expired;
903 }
904 ToastTimerTick::Continue
905 });
906 match tick {
907 ToastTimerTick::Continue => {}
908 ToastTimerTick::Closed(callback) => {
909 if let Some(cb) = callback {
910 cx.update(|cx| cb(cx));
911 }
912 return;
913 }
914 ToastTimerTick::Expired => {
915 cx.update(|cx| ToastStore::close(&store, id, cx));
916 return;
917 }
918 }
919 }
920 })
921 .detach();
922}
923
924pub fn push_toast(toast: Toast, cx: &mut App) -> u64 {
926 toast.timeout(Duration::ZERO).push(None, cx)
927}
928
929pub fn dismiss_toast(id: u64, cx: &mut App) {
931 let store = toast_store(cx);
932 ToastStore::close(&store, id, cx);
933}
934
935pub fn clear_toasts(cx: &mut App) {
937 let store = toast_store(cx);
938 ToastStore::clear(&store, cx);
939}
940
941pub fn pause_toasts(paused: bool, cx: &mut App) {
943 let store = toast_store(cx);
944 store.update(cx, |s, cx| {
945 if paused {
946 s.pause_all();
947 } else {
948 s.resume_all();
949 }
950 cx.notify();
951 });
952}
953
954#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
958#[derive(IntoElement)]
959pub struct ToastViewport {
960 placement: ToastPlacement,
961 gap: Pixels,
962 max_visible_toasts: usize,
963 width: Pixels,
964 inset: Pixels,
965 scale_factor: f32,
966 is_expanded: bool,
969 exit_duration: Duration,
971 hotkey: ToastHotkey,
973 id: Option<ElementId>,
974 sx: Option<Box<gpui::StyleRefinement>>,
976}
977
978impl ToastViewport {
979 pub fn new() -> Self {
981 Self {
982 placement: ToastPlacement::default(),
983 gap: px(12.),
984 max_visible_toasts: DEFAULT_MAX_VISIBLE_TOASTS,
985 scale_factor: 0.05,
986 width: px(460.),
987 inset: px(16.),
988 is_expanded: false,
989 exit_duration: DEFAULT_TOAST_EXIT_DURATION,
990 hotkey: DEFAULT_TOAST_HOTKEY,
991 id: None,
992 sx: None,
993 }
994 }
995
996 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
1000 self.id = Some(id.into());
1001 self
1002 }
1003
1004 pub fn placement(mut self, placement: ToastPlacement) -> Self {
1006 self.placement = placement;
1007 self
1008 }
1009
1010 pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
1012 self.gap = gap.into();
1013 self
1014 }
1015
1016 pub fn scale_factor(mut self, factor: f32) -> Self {
1023 self.scale_factor = factor.clamp(0.0, 1.0);
1024 self
1025 }
1026
1027 pub fn max_visible_toasts(mut self, n: usize) -> Self {
1029 self.max_visible_toasts = n.max(1);
1030 self
1031 }
1032
1033 pub fn is_expanded(mut self, expanded: bool) -> Self {
1037 self.is_expanded = expanded;
1038 self
1039 }
1040
1041 pub fn exit_duration(mut self, duration: Duration) -> Self {
1044 self.exit_duration = duration;
1045 self
1046 }
1047
1048 pub fn hotkey(mut self, hotkey: ToastHotkey) -> Self {
1051 self.hotkey = hotkey;
1052 self
1053 }
1054
1055 pub fn width(mut self, width: impl Into<Pixels>) -> Self {
1057 self.width = width.into();
1058 self
1059 }
1060
1061 pub fn inset(mut self, inset: impl Into<Pixels>) -> Self {
1063 self.inset = inset.into();
1064 self
1065 }
1066
1067 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
1074 crate::util::refine_sx(&mut self.sx, style);
1075 self
1076 }
1077}
1078
1079impl Default for ToastViewport {
1080 fn default() -> Self {
1081 Self::new()
1082 }
1083}
1084
1085impl RenderOnce for ToastViewport {
1086 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1087 let store = cx.try_global::<ToastHub>().map(|hub| hub.store.clone());
1088 let (mut toasts, exiting, exiting_frontmost) = match store.as_ref() {
1089 Some(store) => {
1090 let snap = store.read(cx);
1091 (
1092 snap.toasts().to_vec(),
1093 snap.exiting().to_vec(),
1094 snap.exit_frontmost.clone(),
1095 )
1096 }
1097 None => (Vec::new(), Vec::new(), HashSet::new()),
1098 };
1099
1100 let region_id = self
1101 .id
1102 .clone()
1103 .unwrap_or_else(|| ElementId::Name("toast-region".into()));
1104 let pointer_state =
1105 window.use_keyed_state(element_id::scoped(®ion_id, "pointer"), cx, |_, _| false);
1106 let region_focus = window
1107 .use_keyed_state(element_id::scoped(®ion_id, "focus"), cx, |_, cx| {
1108 cx.focus_handle().tab_stop(false)
1109 })
1110 .read(cx)
1111 .clone();
1112 let measured_height_state =
1118 window.use_keyed_state(element_id::scoped(®ion_id, "heights"), cx, |_, _| {
1119 Rc::new(RefCell::new(HashMap::<u64, Pixels>::new()))
1120 });
1121 let measured_heights = measured_height_state.read(cx).clone();
1122 let _hotkey_sub =
1123 window.use_keyed_state(element_id::scoped(®ion_id, "hotkey"), cx, |_, cx| {
1124 let focus = region_focus.clone();
1125 cx.intercept_keystrokes(move |event, window, cx| {
1126 let Some(hub) = cx.try_global::<ToastHub>() else {
1127 return;
1128 };
1129 let hotkey = hub.store.read(cx).hotkey.clone();
1130 if !hotkey.matches(&event.keystroke) {
1131 return;
1132 }
1133 if hub.store.read(cx).toasts().is_empty() {
1134 return;
1135 }
1136 focus.focus(window, cx);
1137 cx.stop_propagation();
1138 })
1139 });
1140
1141 let max_visible = self.max_visible_toasts;
1142 let hidden: Vec<ToastData> = if toasts.len() > max_visible {
1143 toasts.split_off(max_visible)
1144 } else {
1145 Vec::new()
1146 };
1147 let live_ids: HashSet<u64> = toasts
1151 .iter()
1152 .chain(hidden.iter())
1153 .chain(exiting.iter())
1154 .map(|toast| toast.id)
1155 .collect();
1156 measured_heights
1157 .borrow_mut()
1158 .retain(|id, _| live_ids.contains(id));
1159 let active_count = toasts.len();
1160 let pointer_within = *pointer_state.read(cx);
1161 let focus_within = region_focus.contains_focused(window, cx);
1162 let interaction_within = (pointer_within || focus_within) && active_count > 0;
1163 let expanded = (self.is_expanded || interaction_within) && active_count > 1;
1165 if active_count == 0 && pointer_within {
1166 pointer_state.update(cx, |held, _| *held = false);
1167 }
1168
1169 if let Some(store) = store.as_ref() {
1170 store.update(cx, |s, _| {
1171 s.interaction_paused = interaction_within;
1172 s.exit_duration = self.exit_duration;
1173 s.hotkey = self.hotkey.clone();
1174 s.viewport_expanded = expanded;
1175 });
1176 }
1177
1178 let stack_gap = if expanded || active_count <= 1 {
1179 self.gap
1180 } else {
1181 px(0.)
1182 };
1183 let width = px(f32::from(self.width)
1190 .min((f32::from(window.viewport_size().width) - f32::from(self.inset) * 2.0).max(1.0)));
1191 let front_height = toasts
1192 .first()
1193 .and_then(|toast| measured_heights.borrow().get(&toast.id).copied())
1194 .unwrap_or(px(0.));
1195 let have_all_heights = active_count > 0
1196 && front_height > px(0.)
1197 && (!expanded
1198 || toasts
1199 .iter()
1200 .all(|toast| measured_heights.borrow().contains_key(&toast.id)));
1201 let stack_absolute = have_all_heights;
1202 let expanded_height = if expanded {
1203 let content_height = toasts.iter().fold(0.0, |height, toast| {
1204 height
1205 + measured_heights
1206 .borrow()
1207 .get(&toast.id)
1208 .map_or(0.0, |value| f32::from(*value))
1209 });
1210 px(content_height + f32::from(self.gap) * active_count.saturating_sub(1) as f32)
1211 } else {
1212 px(f32::from(front_height)
1213 + f32::from(self.gap) * active_count.saturating_sub(1) as f32)
1214 };
1215 let mut region = gpui::div()
1216 .id(region_id)
1217 .track_focus(®ion_focus)
1218 .absolute();
1219 if stack_absolute {
1220 region = region.h(expanded_height);
1225 } else {
1226 region = region.flex().flex_col().gap(stack_gap);
1227 }
1228 region = region
1229 .on_hover({
1230 let pointer_state = pointer_state.clone();
1231 move |over, window, cx| {
1232 pointer_state.update(cx, |held, cx| {
1233 if *held != *over {
1234 *held = *over;
1235 cx.notify();
1236 }
1237 });
1238 window.refresh();
1239 }
1240 })
1241 .on_key_down({
1242 let pointer_state = pointer_state.clone();
1243 move |event, window, cx| {
1244 if event.keystroke.key != "escape" {
1245 return;
1246 }
1247 pointer_state.update(cx, |held, cx| {
1248 *held = false;
1249 cx.notify();
1250 });
1251 window.blur(cx);
1252 window.refresh();
1253 }
1254 });
1255
1256 region = if self.placement.is_top() {
1257 region.top(self.inset)
1258 } else {
1259 region.bottom(self.inset)
1260 };
1261
1262 region = match self.placement {
1263 ToastPlacement::TopStart | ToastPlacement::BottomStart => {
1264 let region = region.left(self.inset);
1265 if stack_absolute {
1266 region.w(width)
1267 } else {
1268 region
1269 }
1270 }
1271 ToastPlacement::TopEnd | ToastPlacement::BottomEnd => {
1272 let region = region.right(self.inset);
1273 if stack_absolute {
1274 region.w(width)
1275 } else {
1276 region
1277 }
1278 }
1279 ToastPlacement::Top | ToastPlacement::Bottom => {
1281 region.left_0().right_0().items_center()
1282 }
1283 };
1284
1285 let scale = self.scale_factor;
1289 let peek = self.gap;
1290 let top = self.placement.is_top();
1291 let visible_len = toasts.len();
1292 let last = visible_len.saturating_sub(1);
1293 let n = active_count + hidden.len();
1294 let align_end = matches!(
1295 self.placement,
1296 ToastPlacement::TopEnd | ToastPlacement::BottomEnd
1297 );
1298 let align_center = matches!(self.placement, ToastPlacement::Top | ToastPlacement::Bottom);
1299 let mut specs: Vec<(ToastData, usize, Pixels)> = Vec::with_capacity(visible_len);
1300 if stack_absolute {
1301 let mut offset = 0.0;
1306 for (depth, toast) in toasts.into_iter().enumerate() {
1307 specs.push((toast.clone(), depth, px(offset)));
1308 if expanded {
1309 offset += measured_heights
1310 .borrow()
1311 .get(&toast.id)
1312 .map_or(0.0, |value| f32::from(*value))
1313 + f32::from(peek);
1314 } else {
1315 offset = f32::from(peek) * (depth + 1) as f32;
1316 }
1317 }
1318 specs.reverse();
1319 } else {
1320 if !top {
1321 toasts.reverse();
1322 }
1323 for (i, toast) in toasts.into_iter().enumerate() {
1324 let depth = if top { i } else { last - i };
1325 specs.push((toast, depth, px(0.)));
1326 }
1327 }
1328 let visible_heights = measured_heights.clone();
1329 let visible_height_state = measured_height_state.clone();
1330 let visible_cards = specs
1331 .into_iter()
1332 .map(move |(t, depth, offset)| ToastCardEl {
1333 t,
1334 width,
1335 depth: if expanded && !stack_absolute {
1336 0
1337 } else {
1338 depth
1339 },
1340 scale_factor: scale,
1341 frontmost: depth == 0,
1342 expanded,
1343 hidden: false,
1344 exiting: false,
1345 peek,
1346 stack_absolute,
1347 stack_offset: offset,
1348 front_height,
1349 stack_top: top,
1350 align_end,
1351 align_center,
1352 measured_heights: visible_heights.clone(),
1353 height_state: visible_height_state.clone(),
1354 });
1355 let hidden_heights = measured_heights.clone();
1356 let hidden_height_state = measured_height_state.clone();
1357 let hidden_cards = hidden.into_iter().map(move |t| ToastCardEl {
1358 t,
1359 width,
1360 depth: 0,
1361 scale_factor: scale,
1362 frontmost: false,
1363 expanded,
1364 hidden: true,
1365 exiting: false,
1366 peek,
1367 stack_absolute,
1368 stack_offset: px(0.),
1369 front_height,
1370 stack_top: top,
1371 align_end,
1372 align_center,
1373 measured_heights: hidden_heights.clone(),
1374 height_state: hidden_height_state.clone(),
1375 });
1376 let exiting_heights = measured_heights;
1377 let exiting_height_state = measured_height_state;
1378 let exiting_cards = exiting.into_iter().map(move |t| {
1379 let frontmost = exiting_frontmost.contains(&t.id);
1380 ToastCardEl {
1381 t,
1382 width,
1383 depth: 0,
1384 scale_factor: scale,
1385 frontmost,
1386 expanded,
1387 hidden: false,
1388 exiting: true,
1389 peek,
1390 stack_absolute,
1391 stack_offset: px(0.),
1392 front_height,
1393 stack_top: top,
1394 align_end,
1395 align_center,
1396 measured_heights: exiting_heights.clone(),
1397 height_state: exiting_height_state.clone(),
1398 }
1399 });
1400 let region = region
1401 .children(visible_cards)
1402 .children(hidden_cards)
1403 .children(exiting_cards);
1404 let region = crate::util::apply_sx(region, &self.sx);
1405 if self.id.is_some() {
1406 let name = if n == 1 {
1407 SharedString::from("1 notification.")
1408 } else {
1409 SharedString::from(format!("{n} notifications."))
1410 };
1411 region
1412 .a11y_named(a11y::Role::Region, &a11y::Name::labelled(name))
1413 .into_any_element()
1414 } else {
1415 region.into_any_element()
1416 }
1417 }
1418}
1419
1420#[derive(IntoElement)]
1421struct ToastCardEl {
1422 t: ToastData,
1423 width: Pixels,
1424 depth: usize,
1425 scale_factor: f32,
1426 frontmost: bool,
1427 expanded: bool,
1428 hidden: bool,
1429 exiting: bool,
1430 peek: Pixels,
1431 stack_absolute: bool,
1432 stack_offset: Pixels,
1433 front_height: Pixels,
1434 stack_top: bool,
1435 align_end: bool,
1436 align_center: bool,
1437 measured_heights: Rc<RefCell<HashMap<u64, Pixels>>>,
1438 height_state: Entity<Rc<RefCell<HashMap<u64, Pixels>>>>,
1439}
1440
1441#[derive(Clone, Copy, Debug, Default)]
1442struct ToastCloseMotion {
1443 visible: bool,
1444 generation: usize,
1445}
1446
1447impl RenderOnce for ToastCardEl {
1448 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1449 let base_id = ElementId::named_usize("toast", self.t.id as usize);
1455 let interactive = !self.hidden && !self.exiting && (self.frontmost || self.expanded);
1458 let collapsed_behind = !self.frontmost && !self.expanded && !self.hidden && !self.exiting;
1459 let close_focus = if self.t.closable && interactive {
1460 Some(crate::util::tab_stop_handle(
1461 element_id::scoped(&element_id::scoped(&base_id, "close"), "focus"),
1462 window,
1463 cx,
1464 ))
1465 } else {
1466 None
1467 };
1468 let close_hovered =
1469 window.use_keyed_state(element_id::scoped(&base_id, "close-hovered"), cx, |_, _| {
1470 false
1471 });
1472 let close_motion =
1473 window.use_keyed_state(element_id::scoped(&base_id, "close-motion"), cx, |_, _| {
1474 ToastCloseMotion::default()
1475 });
1476 let close_focused = close_focus
1477 .as_ref()
1478 .is_some_and(|focus| focus.is_focused(window));
1479 let close_target = interactive && (*close_hovered.read(cx) || close_focused);
1480 let mut close_frame = *close_motion.read(cx);
1481 if close_frame.visible != close_target {
1482 close_frame.visible = close_target;
1483 close_frame.generation = close_frame.generation.wrapping_add(1);
1484 close_motion.update(cx, |current, _| *current = close_frame);
1485 }
1486 let close_visible = close_frame.visible;
1487 let custom_indicator = if self.t.is_loading {
1491 None
1492 } else {
1493 self.t
1494 .indicator_content
1495 .as_ref()
1496 .map(|render_indicator| render_indicator(cx))
1497 };
1498 let colors = cx.colors();
1499 let sem = cx.role(self.t.color);
1500 let title_color = match self.t.color {
1501 Color::Default => colors.overlay.foreground,
1502 Color::Accent | Color::Success | Color::Warning | Color::Danger => {
1503 sem.soft_foreground(colors.foreground)
1504 }
1505 };
1506 let indicator_color = match self.t.color {
1507 Color::Default | Color::Accent => colors.overlay.foreground,
1508 Color::Success | Color::Warning | Color::Danger => {
1509 sem.soft_foreground(colors.foreground)
1510 }
1511 };
1512
1513 let radius = self
1517 .t
1518 .radius
1519 .unwrap_or_else(|| crate::util::container_radius(cx));
1520 let panel_padding_y = self.t.padding.unwrap_or(px(12.));
1522 let panel_padding_x = self.t.padding.unwrap_or(px(16.));
1523 let shrink = (1.0 - self.scale_factor * self.depth as f32).clamp(0.5, 1.0);
1527 let width = px(f32::from(self.width) * shrink);
1528 let should_measure = !self.hidden && !self.exiting && (self.frontmost || self.expanded);
1529 let mut card = gpui::div();
1538 if should_measure {
1539 let measured_heights = self.measured_heights.clone();
1540 let height_state = self.height_state.clone();
1541 let toast_id = self.t.id;
1542 card = card.on_children_prepainted(move |bounds, _, cx| {
1543 let content_height = bounds
1544 .iter()
1545 .map(|bound| f32::from(bound.size.height))
1546 .fold(0.0, f32::max);
1547 let next = px(content_height + f32::from(panel_padding_y) * 2.0);
1548 let mut heights = measured_heights.borrow_mut();
1549 if heights.get(&toast_id).copied() != Some(next) {
1550 heights.insert(toast_id, next);
1551 height_state.update(cx, |_, cx| cx.notify());
1552 }
1553 });
1554 }
1555 let mut card = card
1556 .id(base_id.clone())
1557 .a11y_named(
1558 a11y::Role::AlertDialog,
1559 &a11y::Name::labelled(self.t.title.clone()).described(self.t.description.clone()),
1560 )
1561 .w(width)
1562 .flex()
1563 .items_start()
1564 .gap(px(6.))
1565 .px(panel_padding_x)
1566 .py(panel_padding_y)
1567 .relative()
1568 .rounded(radius)
1569 .bg(colors.surface.background)
1570 .text_color(colors.overlay.foreground)
1571 .when(!cx.layout().overlay_shadow.is_empty(), |c| {
1572 c.shadow(cx.layout().overlay_shadow.clone())
1573 });
1574 if interactive {
1575 let close_hovered = close_hovered.clone();
1576 card = card.on_hover(move |over, window, cx| {
1577 close_hovered.update(cx, |held, cx| {
1578 if *held != *over {
1579 *held = *over;
1580 cx.notify();
1581 }
1582 });
1583 window.refresh();
1584 });
1585 }
1586 if self.hidden {
1587 card = card
1590 .when(!self.stack_absolute, |card| card.absolute())
1591 .w(px(0.))
1592 .h(px(0.))
1593 .opacity(0.);
1594 } else if collapsed_behind {
1595 let collapsed_height = if self.stack_absolute {
1600 self.front_height
1601 } else {
1602 self.peek
1603 };
1604 card = card.h(collapsed_height).overflow_hidden();
1605 } else if self.exiting && !self.stack_absolute {
1606 card = card.absolute();
1607 }
1608
1609 if self.t.is_loading {
1613 card = card.child(
1614 gpui::div()
1615 .flex()
1616 .flex_shrink_0()
1617 .p(px(4.))
1618 .when(collapsed_behind, |c| c.opacity(0.))
1619 .child(
1620 crate::spinner::Spinner::new(element_id::scoped(&base_id, "spinner"))
1621 .size(herogpui_core::Size::Sm)
1622 .current_color(indicator_color),
1623 ),
1624 );
1625 } else if let Some(custom_indicator) = custom_indicator {
1626 card = card.child(
1627 gpui::div()
1628 .flex()
1629 .flex_shrink_0()
1630 .items_center()
1631 .justify_center()
1632 .p(px(4.))
1633 .when(collapsed_behind, |c| c.opacity(0.))
1634 .child(custom_indicator),
1635 );
1636 } else if let Some(icon) = self.t.indicator.clone().or_else(|| {
1637 if self.t.indicator_set {
1640 None
1641 } else {
1642 default_indicator(self.t.color).map(SharedString::from)
1643 }
1644 }) {
1645 card = card.child(
1646 gpui::div()
1647 .flex()
1648 .flex_shrink_0()
1649 .items_center()
1650 .justify_center()
1651 .p(px(4.))
1652 .when(collapsed_behind, |c| c.opacity(0.))
1653 .child(
1654 gpui::svg()
1655 .size(px(16.))
1656 .path(icon)
1657 .text_color(indicator_color),
1658 ),
1659 );
1660 }
1661
1662 let mut text_col = gpui::div()
1665 .flex()
1666 .flex_col()
1667 .flex_1()
1668 .min_w_0()
1669 .when(collapsed_behind, |c| c.opacity(0.));
1670 text_col = text_col.child(
1671 gpui::div()
1672 .text_size(px(14.))
1674 .line_height(px(20.))
1675 .font_weight(gpui::FontWeight::MEDIUM)
1676 .text_color(title_color)
1677 .whitespace_normal()
1681 .child(self.t.title.to_string()),
1682 );
1683 if let Some(desc) = &self.t.description {
1684 text_col = text_col.child(
1685 gpui::div()
1686 .text_size(px(14.))
1688 .line_height(px(20.))
1689 .text_color(colors.muted)
1690 .child(desc.to_string()),
1691 );
1692 }
1693 card = card.child(text_col);
1694
1695 if let Some((label, on_press)) = self.t.action.clone() {
1697 let id = self.t.id;
1698 let mut action = crate::button::Button::new(element_id::scoped(&base_id, "action"))
1699 .label(label)
1700 .variant(herogpui_core::Variant::Secondary)
1701 .size(herogpui_core::Size::Sm)
1702 .is_disabled(!interactive);
1703 if interactive {
1704 action = action.on_press(move |_, _, cx| {
1705 on_press(cx);
1706 dismiss_toast(id, cx);
1708 });
1709 }
1710 card = card.child(action);
1711 }
1712
1713 if self.t.closable {
1714 let id = self.t.id;
1715 let hover_bg = self.t.close_hover_bg.unwrap_or(colors.default.color);
1716 let radius = crate::util::small_radius(cx);
1717 let mut close_btn = gpui::div()
1718 .id(element_id::scoped(&base_id, "close"))
1719 .flex()
1720 .items_center()
1721 .justify_center()
1722 .size(px(20.))
1728 .absolute()
1729 .top(px(-4.))
1730 .right(px(-4.))
1731 .rounded(radius);
1735 if interactive {
1736 close_btn = crate::util::cursor_interactive(close_btn, cx);
1737 let close_hovered_for_click = close_hovered;
1741 let close_focus_for_click = close_focus.clone();
1742 close_btn = close_btn.on_click(move |_, window, cx| {
1743 let visible = *close_hovered_for_click.read(cx)
1744 || close_focus_for_click
1745 .as_ref()
1746 .is_some_and(|focus| focus.is_focused(window));
1747 if visible {
1748 dismiss_toast(id, cx);
1749 }
1750 });
1751 let close_focus = close_focus
1755 .as_ref()
1756 .expect("a frontmost closable toast created its close handle");
1757 close_btn = crate::util::ring_overlay_if_focused(
1761 close_btn.track_focus(close_focus),
1762 close_focus,
1763 true,
1764 radius,
1765 Vec::new(),
1766 window,
1767 cx,
1768 );
1769 } else {
1770 close_btn = close_btn.opacity(0.);
1773 }
1774 let close_visual = gpui::div()
1775 .absolute()
1776 .inset_0()
1777 .flex()
1778 .items_center()
1779 .justify_center()
1780 .border(cx.layout().border_width)
1781 .border_color(colors.border)
1782 .bg(colors.overlay.background)
1783 .rounded(radius)
1784 .hover(move |s| s.bg(hover_bg))
1785 .child(
1786 gpui::svg()
1787 .size(px(12.))
1788 .path(icons::CLOSE)
1789 .text_color(colors.muted),
1790 );
1791 let close_visual: AnyElement = if close_frame.generation > 0
1792 && !ActiveTheme::reduce_motion(cx)
1793 {
1794 let visible = close_visible;
1795 let from = if visible { 0.0 } else { 1.0 };
1796 let to = if visible { 1.0 } else { 0.0 };
1797 close_visual
1798 .with_animation(
1799 element_id::indexed(&base_id, "close-opacity", close_frame.generation),
1800 gpui::Animation::new(Duration::from_millis(crate::anim::TOAST_OPACITY_MS))
1801 .with_easing(|t| crate::anim::Curve::Smooth.at(t)),
1802 move |el, delta| el.opacity(from + (to - from) * delta),
1803 )
1804 .into_any_element()
1805 } else {
1806 close_visual
1807 .opacity(if close_visible { 1.0 } else { 0.0 })
1808 .into_any_element()
1809 };
1810 card = card.child(close_btn.child(close_visual));
1811 }
1812
1813 let card_height = if self.hidden {
1814 px(0.)
1815 } else if collapsed_behind {
1816 self.front_height
1817 } else {
1818 self.measured_heights
1819 .borrow()
1820 .get(&self.t.id)
1821 .copied()
1822 .unwrap_or(self.front_height)
1823 };
1824 let travel = if card_height > px(0.) {
1829 card_height
1830 } else if self.front_height > px(0.) {
1831 self.front_height
1832 } else {
1833 px(44.)
1834 };
1835 let edge = if self.stack_top {
1836 crate::anim::Edge::Top
1837 } else {
1838 crate::anim::Edge::Bottom
1839 };
1840 let animated = if self.exiting && self.frontmost {
1841 toast_exiting(
1842 card,
1843 element_id::scoped(&base_id, "anim"),
1844 edge,
1845 travel,
1846 crate::anim::Motion::TOAST_OUT,
1847 cx,
1848 )
1849 } else if self.exiting {
1850 crate::anim::exiting(
1851 card,
1852 element_id::scoped(&base_id, "anim"),
1853 crate::anim::ZoomBox::panel(panel_padding_y, radius)
1854 .padding_x(panel_padding_x)
1855 .sized(width),
1856 crate::anim::Motion::TOAST_STACK_OUT,
1857 cx,
1858 )
1859 } else {
1860 toast_entering(
1861 card,
1862 element_id::scoped(&base_id, "anim"),
1863 edge,
1864 travel,
1865 crate::anim::Motion::TOAST_IN,
1866 cx,
1867 )
1868 };
1869 if self.stack_absolute {
1870 let slot_id = self.t.id;
1871 let mut slot = gpui::div()
1872 .absolute()
1873 .left_0()
1874 .right_0()
1875 .h(card_height)
1876 .flex()
1877 .debug_selector(move || format!("toast-slot-{slot_id}"));
1878 slot = if self.stack_top {
1879 slot.top(self.stack_offset)
1880 } else {
1881 slot.bottom(self.stack_offset)
1882 };
1883 slot = if self.align_end {
1884 slot.justify_end()
1885 } else if self.align_center {
1886 slot.justify_center()
1887 } else {
1888 slot.justify_start()
1889 };
1890 slot.child(animated).into_any_element()
1891 } else {
1892 animated
1893 }
1894 }
1895}
1896
1897fn default_indicator(color: Color) -> Option<&'static str> {
1901 match color {
1902 Color::Default | Color::Accent => Some(icons::INFO_CIRCLE),
1903 Color::Success => Some(icons::CHECK_CIRCLE),
1904 Color::Warning => Some(icons::WARNING_TRIANGLE),
1905 Color::Danger => Some(icons::CIRCLE_EXCLAMATION),
1906 }
1907}
1908
1909#[cfg(test)]
1910mod tests {
1911 use super::*;
1912
1913 fn implementation_source() -> &'static str {
1914 include_str!("toast.rs")
1915 .split("#[cfg(test)]")
1916 .next()
1917 .expect("the implementation section is always present")
1918 }
1919
1920 #[test]
1921 fn default_uses_info_indicator() {
1922 assert_eq!(default_indicator(Color::Default), Some(icons::INFO_CIRCLE));
1923 }
1924
1925 #[test]
1926 fn accent_uses_the_pinned_info_indicator() {
1927 assert_eq!(default_indicator(Color::Accent), Some(icons::INFO_CIRCLE));
1928 }
1929
1930 #[test]
1931 fn success_uses_check_circle_indicator() {
1932 assert_eq!(default_indicator(Color::Success), Some(icons::CHECK_CIRCLE));
1933 }
1934
1935 #[test]
1936 fn warning_uses_warning_triangle_indicator() {
1937 assert_eq!(
1938 default_indicator(Color::Warning),
1939 Some(icons::WARNING_TRIANGLE)
1940 );
1941 }
1942
1943 #[test]
1944 fn danger_uses_circle_exclamation_indicator() {
1945 assert_eq!(
1946 default_indicator(Color::Danger),
1947 Some(icons::CIRCLE_EXCLAMATION)
1948 );
1949 }
1950
1951 #[test]
1952 fn neutral_indicators_use_the_overlay_foreground_token() {
1953 let indicator = implementation_source()
1954 .split("let indicator_color = match self.t.color")
1955 .nth(1)
1956 .expect("the indicator color implementation is present")
1957 .split("let mut card =")
1958 .next()
1959 .expect("the card implementation follows the indicator color");
1960 assert!(
1961 indicator.contains("Color::Default | Color::Accent => colors.overlay.foreground"),
1962 "default and accent indicators must use `text-overlay-foreground`"
1963 );
1964 }
1965
1966 #[test]
1967 fn semantic_indicators_use_their_soft_foreground_tokens() {
1968 let indicator = implementation_source()
1969 .split("let indicator_color = match self.t.color")
1970 .nth(1)
1971 .expect("the indicator color implementation is present")
1972 .split("let mut card =")
1973 .next()
1974 .expect("the card implementation follows the indicator color");
1975 assert!(
1976 indicator.contains("Color::Success | Color::Warning | Color::Danger =>"),
1977 "status indicators must branch separately from neutral indicators"
1978 );
1979 }
1980
1981 #[test]
1982 fn loading_indicator_inherits_the_status_indicator_color() {
1983 let loading = implementation_source()
1984 .split("// `.toast__indicator`")
1985 .nth(1)
1986 .expect("the loading indicator implementation is present")
1987 .split("if self.t.is_loading {")
1988 .nth(1)
1989 .expect("the loading branch follows the indicator marker")
1990 .split("} else if let Some(icon)")
1991 .next()
1992 .expect("the custom indicator implementation follows loading");
1993 assert!(
1994 loading.contains(".current_color(indicator_color)"),
1995 "the loading spinner must inherit the toast indicator color"
1996 );
1997 }
1998
1999 #[test]
2000 fn custom_indicator_content_keeps_the_shared_indicator_box() {
2001 let source = implementation_source();
2002 assert!(source.contains("pub fn indicator_content<E, F>"));
2003 assert!(source.contains(".indicator_content\n .as_ref()"));
2004 let custom = source
2005 .split(".indicator_content\n .as_ref()")
2006 .nth(1)
2007 .expect("the custom indicator branch is present")
2008 .split("} else if let Some(icon)")
2009 .next()
2010 .expect("the default indicator follows the custom branch");
2011 assert!(custom.contains(".p(px(4.))"));
2012 assert!(custom.contains("render_indicator(cx)"));
2013 }
2014
2015 #[test]
2016 fn toast_card_does_not_add_a_border() {
2017 let card = implementation_source()
2018 .split("let mut card =")
2019 .nth(1)
2020 .expect("the card implementation is present")
2021 .split("// `.toast__indicator`")
2022 .next()
2023 .expect("the indicator implementation follows the card");
2024 assert!(!card.contains(".border(cx.layout().border_width)"));
2025 assert!(!card.contains(".border_color(colors.border)"));
2026 }
2027
2028 #[test]
2029 fn toast_content_does_not_add_an_extra_gap() {
2030 let content = implementation_source()
2031 .split("let mut text_col =")
2032 .nth(1)
2033 .expect("the content implementation is present")
2034 .split("// `.toast__action`")
2035 .next()
2036 .expect("the action implementation follows the content");
2037 assert!(!content.contains(".gap(px(2.))"));
2038 }
2039
2040 #[test]
2041 fn toast_titles_wrap_instead_of_truncating() {
2042 let content = implementation_source()
2043 .split("let mut text_col =")
2044 .nth(1)
2045 .expect("the toast content implementation is present")
2046 .split("if let Some(desc)")
2047 .next()
2048 .expect("the description follows the title");
2049 assert!(
2050 content.contains(".whitespace_normal()"),
2051 "the pinned toast title has normal wrapping"
2052 );
2053 assert!(
2054 !content.contains(".truncate()"),
2055 "toast titles must remain readable when they exceed one line"
2056 );
2057 }
2058
2059 #[test]
2060 fn toast_close_button_is_out_of_flow() {
2061 let close = implementation_source()
2062 .split("let mut close_btn =")
2063 .nth(1)
2064 .expect("the close button implementation is present")
2065 .split("if interactive")
2066 .next()
2067 .expect("the close interaction follows its geometry");
2068 assert!(close.contains(".absolute()"));
2069 assert!(close.contains(".top(px(-4.))"));
2070 assert!(close.contains(".right(px(-4.))"));
2071 assert!(close.contains(".rounded(radius)"));
2072 }
2073
2074 #[test]
2075 fn toast_close_button_reveals_on_card_hover_and_fades() {
2076 let source = implementation_source();
2077 assert!(source.contains("close-hovered"));
2078 assert!(source.contains("close-opacity"));
2079 assert!(source.contains("TOAST_OPACITY_MS"));
2080 assert!(source.contains("close_focus_for_click"));
2081 assert!(source.contains("if visible {\n dismiss_toast"));
2082 assert!(source.contains("!ActiveTheme::reduce_motion(cx)"));
2083 }
2084
2085 #[test]
2086 fn toast_viewport_clamps_width_to_the_live_window() {
2087 let source = implementation_source();
2088 let width = source
2089 .split("let width = px(")
2090 .nth(1)
2091 .expect("the viewport width clamp is present")
2092 .split("let front_height")
2093 .next()
2094 .expect("the width is resolved before stack layout");
2095 assert!(width.contains("window.viewport_size().width"));
2096 assert!(width.contains("self.inset"));
2097 assert!(width.contains(".min("));
2098 assert!(source.contains("region.w(width)"));
2099 }
2100
2101 #[test]
2102 fn toast_motion_uses_the_physical_placement_edge() {
2103 let source = implementation_source();
2104 assert!(source.contains("toast_entering("));
2105 assert!(source.contains("toast_exiting("));
2106 assert!(source.contains("crate::anim::Motion::TOAST_IN"));
2107 assert!(source.contains("crate::anim::Motion::TOAST_OUT"));
2108 assert!(source.contains("crate::anim::Motion::TOAST_STACK_OUT"));
2109 assert!(source.contains("TOAST_OPACITY_MS"));
2110 assert!(source.contains("let edge = if self.stack_top"));
2111 assert!(source.contains("self.exiting && self.frontmost"));
2112 }
2113
2114 #[test]
2118 fn the_close_button_hovers_the_full_default() {
2119 let source = implementation_source();
2122 assert!(
2123 source
2124 .contains("let hover_bg = self.t.close_hover_bg.unwrap_or(colors.default.color);"),
2125 "the toast close button must default to `bg-default` and honor \
2126 the named override (pinned `.toast__close-button:hover`)"
2127 );
2128 assert!(
2129 !source.contains("soft_hover()"),
2130 "the close button must not come back on a soft token"
2131 );
2132 }
2133}
2134
2135crate::util::impl_component_styled!(ToastViewport);