Skip to main content

fret_ui_kit/primitives/
toast.rs

1//! Radix-aligned toast primitives.
2//!
3//! Upstream reference: `@radix-ui/react-toast` (`repo-ref/primitives/packages/react/toast`).
4//!
5//! Fret does not model DOM portals. Live region announcements are modeled via the semantics
6//! contract (`SemanticsFlags.live` / `live_atomic`) and are published by the toast viewport
7//! overlay root. This module focuses on
8//! the reusable core outcomes:
9//! - a per-window toast store with upsert-by-id
10//! - a viewport root installed as a window overlay layer
11//! - optional max-toasts limiting
12//!
13//! The shadcn `Sonner` wrapper builds on top of this substrate.
14
15use fret_core::{AppWindowId, Px};
16use fret_runtime::Model;
17use fret_ui::action::UiActionHost;
18use fret_ui::element::AnyElement;
19use fret_ui::{ElementContext, UiHost};
20
21use crate::window_overlays;
22use crate::{OverlayController, OverlayRequest};
23
24pub use window_overlays::{
25    DEFAULT_MAX_TOASTS, DEFAULT_SWIPE_DRAGGING_THRESHOLD_PX, DEFAULT_SWIPE_MAX_DRAG_PX,
26    DEFAULT_SWIPE_THRESHOLD_PX, ToastAction, ToastId, ToastPosition, ToastRequest, ToastStore,
27    ToastSwipeConfig, ToastSwipeDirection, ToastVariant,
28};
29
30#[derive(Debug, Clone, Copy)]
31pub struct ToastViewport {
32    position: ToastPosition,
33    margin: Option<Px>,
34    gap: Option<Px>,
35    toast_min_width: Option<Px>,
36    toast_max_width: Option<Px>,
37    max_toasts: Option<usize>,
38    swipe_direction: ToastSwipeDirection,
39    swipe_threshold: Px,
40    swipe_max_drag: Px,
41    swipe_dragging_threshold: Px,
42}
43
44#[derive(Debug, Default)]
45struct ToastViewportConfigState {
46    max_toasts: Option<usize>,
47    swipe_direction: Option<ToastSwipeDirection>,
48    swipe_threshold: Option<Px>,
49    swipe_max_drag: Option<Px>,
50    swipe_dragging_threshold: Option<Px>,
51}
52
53impl Default for ToastViewport {
54    fn default() -> Self {
55        Self {
56            position: ToastPosition::BottomRight,
57            margin: None,
58            gap: None,
59            toast_min_width: None,
60            toast_max_width: None,
61            max_toasts: Some(DEFAULT_MAX_TOASTS),
62            swipe_direction: ToastSwipeDirection::default(),
63            swipe_threshold: Px(DEFAULT_SWIPE_THRESHOLD_PX),
64            swipe_max_drag: Px(DEFAULT_SWIPE_MAX_DRAG_PX),
65            swipe_dragging_threshold: Px(DEFAULT_SWIPE_DRAGGING_THRESHOLD_PX),
66        }
67    }
68}
69
70impl ToastViewport {
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    pub fn position(mut self, position: ToastPosition) -> Self {
76        self.position = position;
77        self
78    }
79
80    pub fn margin(mut self, margin: Px) -> Self {
81        self.margin = Some(margin);
82        self
83    }
84
85    pub fn gap(mut self, gap: Px) -> Self {
86        self.gap = Some(gap);
87        self
88    }
89
90    pub fn toast_min_width(mut self, width: Px) -> Self {
91        self.toast_min_width = Some(width);
92        self
93    }
94
95    pub fn toast_max_width(mut self, width: Px) -> Self {
96        self.toast_max_width = Some(width);
97        self
98    }
99
100    pub fn max_toasts(mut self, max_toasts: usize) -> Self {
101        self.max_toasts = Some(max_toasts.max(1));
102        self
103    }
104
105    pub fn unlimited(mut self) -> Self {
106        self.max_toasts = None;
107        self
108    }
109
110    pub fn swipe_direction(mut self, direction: ToastSwipeDirection) -> Self {
111        self.swipe_direction = direction;
112        self
113    }
114
115    pub fn swipe_threshold(mut self, threshold: Px) -> Self {
116        self.swipe_threshold = threshold;
117        self
118    }
119
120    pub fn swipe_max_drag(mut self, max_drag: Px) -> Self {
121        self.swipe_max_drag = max_drag;
122        self
123    }
124
125    pub fn swipe_dragging_threshold(mut self, threshold: Px) -> Self {
126        self.swipe_dragging_threshold = threshold;
127        self
128    }
129
130    #[track_caller]
131    pub fn into_element<H: UiHost>(self, cx: &mut ElementContext<'_, H>) -> AnyElement {
132        cx.scope(|cx| {
133            let id = cx.root_id();
134            let store = OverlayController::toast_store(&mut *cx.app);
135
136            let config_changed = cx.slot_state(ToastViewportConfigState::default, |st| {
137                let mut changed = false;
138                if st.max_toasts != self.max_toasts {
139                    st.max_toasts = self.max_toasts;
140                    changed = true;
141                }
142                if st.swipe_direction != Some(self.swipe_direction) {
143                    st.swipe_direction = Some(self.swipe_direction);
144                    changed = true;
145                }
146                if st.swipe_threshold != Some(self.swipe_threshold) {
147                    st.swipe_threshold = Some(self.swipe_threshold);
148                    changed = true;
149                }
150                if st.swipe_max_drag != Some(self.swipe_max_drag) {
151                    st.swipe_max_drag = Some(self.swipe_max_drag);
152                    changed = true;
153                }
154                if st.swipe_dragging_threshold != Some(self.swipe_dragging_threshold) {
155                    st.swipe_dragging_threshold = Some(self.swipe_dragging_threshold);
156                    changed = true;
157                }
158                changed
159            });
160            if config_changed {
161                let _ = cx.app.models_mut().update(&store, |st| {
162                    st.set_window_max_toasts(cx.window, self.max_toasts);
163                    st.set_window_swipe_config_with_options(
164                        cx.window,
165                        ToastSwipeConfig {
166                            direction: self.swipe_direction,
167                            threshold: self.swipe_threshold,
168                            max_drag: self.swipe_max_drag,
169                            dragging_threshold: self.swipe_dragging_threshold,
170                        },
171                    );
172                });
173            }
174
175            let mut request = OverlayRequest::toast_layer(id, store).toast_position(self.position);
176            if let Some(margin) = self.margin {
177                request = request.toast_margin(margin);
178            }
179            if let Some(gap) = self.gap {
180                request = request.toast_gap(gap);
181            }
182            if let Some(width) = self.toast_min_width {
183                request = request.toast_min_width(width);
184            }
185            if let Some(width) = self.toast_max_width {
186                request = request.toast_max_width(width);
187            }
188            OverlayController::request(cx, request);
189
190            cx.stack(|_cx| Vec::new())
191        })
192    }
193}
194
195#[derive(Clone)]
196pub struct ToastController {
197    store: Model<ToastStore>,
198}
199
200impl std::fmt::Debug for ToastController {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        f.debug_struct("ToastController")
203            .field("store", &"<model>")
204            .finish()
205    }
206}
207
208impl ToastController {
209    pub fn global<H: UiHost>(app: &mut H) -> Self {
210        Self {
211            store: OverlayController::toast_store(app),
212        }
213    }
214
215    /// Dispatches a toast request.
216    ///
217    /// Note: this is an upsert. If `request.id` is set and still refers to an open toast, the
218    /// existing toast is updated.
219    pub fn toast(
220        &self,
221        host: &mut dyn UiActionHost,
222        window: AppWindowId,
223        request: ToastRequest,
224    ) -> ToastId {
225        OverlayController::toast_action(host, self.store.clone(), window, request)
226    }
227
228    pub fn dismiss(&self, host: &mut dyn UiActionHost, window: AppWindowId, id: ToastId) -> bool {
229        OverlayController::dismiss_toast_action(host, self.store.clone(), window, id)
230    }
231
232    pub fn dismiss_all(&self, host: &mut dyn UiActionHost, window: AppWindowId) -> usize {
233        OverlayController::dismiss_all_toasts_action(host, self.store.clone(), window)
234    }
235}