1use std::time::Duration;
17
18use web_time::Instant;
19
20use crate::state::UiState;
21use crate::style::StyleProfile;
22use crate::tokens;
23use crate::tree::*;
24use crate::widgets::button::button;
25
26pub const DEFAULT_TOAST_TTL: Duration = Duration::from_secs(4);
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum ToastLevel {
36 Default,
37 Success,
38 Warning,
39 Error,
40 Info,
41}
42
43#[derive(Clone, Debug)]
47pub struct ToastSpec {
48 pub level: ToastLevel,
49 pub message: String,
50 pub ttl: Duration,
51}
52
53impl ToastSpec {
54 pub fn new(level: ToastLevel, message: impl Into<String>) -> Self {
55 Self {
56 level,
57 message: message.into(),
58 ttl: DEFAULT_TOAST_TTL,
59 }
60 }
61 pub fn default(message: impl Into<String>) -> Self {
62 Self::new(ToastLevel::Default, message)
63 }
64 pub fn success(message: impl Into<String>) -> Self {
65 Self::new(ToastLevel::Success, message)
66 }
67 pub fn warning(message: impl Into<String>) -> Self {
68 Self::new(ToastLevel::Warning, message)
69 }
70 pub fn error(message: impl Into<String>) -> Self {
71 Self::new(ToastLevel::Error, message)
72 }
73 pub fn info(message: impl Into<String>) -> Self {
74 Self::new(ToastLevel::Info, message)
75 }
76 pub fn with_ttl(mut self, ttl: Duration) -> Self {
77 self.ttl = ttl;
78 self
79 }
80}
81
82#[derive(Clone, Debug)]
86pub struct Toast {
87 pub id: u64,
88 pub level: ToastLevel,
89 pub message: String,
90 pub expires_at: Instant,
91}
92
93pub fn synthesize_toasts(root: &mut El, ui_state: &mut UiState, now: Instant) -> bool {
106 ui_state.toast.queue.retain(|t| t.expires_at > now);
107 if ui_state.toast.queue.is_empty() {
108 return false;
109 }
110 debug_assert_eq!(
111 root.axis,
112 Axis::Overlay,
113 "synthesize_toasts: root must be an Axis::Overlay container so the toast \
114 stack overlays the main view. Wrap your `App::build` return value in \
115 `overlays(main, [])`. Got axis = {:?}",
116 root.axis,
117 );
118 let cards: Vec<El> = ui_state.toast.queue.iter().map(toast_card).collect();
119 root.children.push(toast_stack(cards));
120 let i = root.children.len() - 1;
125 crate::layout::assign_id_appended(&root.computed_id, &mut root.children[i], i);
126 true
127}
128
129fn toast_stack(cards: Vec<El>) -> El {
136 El::new(Kind::Custom("toast_stack"))
137 .children(cards)
138 .fill_size()
139 .layout(|ctx| {
140 let viewport = (ctx.rect_of_id)("root").unwrap_or(ctx.container);
141 let pad = tokens::SPACE_4;
142 let gap = tokens::SPACE_2;
143 let mut rects = Vec::with_capacity(ctx.children.len());
144 let mut bottom = viewport.bottom() - pad;
147 for c in ctx.children.iter().rev() {
148 let (w, h) = (ctx.measure)(c);
149 let x = viewport.right() - w - pad;
150 rects.push(Rect::new(x, bottom - h, w, h));
151 bottom -= h + gap;
152 }
153 rects.reverse();
154 rects
155 })
156}
157
158fn toast_card(t: &Toast) -> El {
163 let accent = level_accent(t.level);
164 let lead = El::new(Kind::Group)
165 .width(Size::Fixed(3.0))
166 .height(Size::Fill(1.0))
167 .fill(accent)
168 .radius(tokens::RADIUS_SM);
169 let body = El::new(Kind::Text)
170 .text(t.message.clone())
171 .text_role(TextRole::Body)
172 .text_color(tokens::FOREGROUND)
173 .text_wrap(TextWrap::Wrap)
174 .width(Size::Fill(1.0));
175 let dismiss = button("×")
176 .key(format!("toast-dismiss-{}", t.id))
177 .secondary();
178
179 El::new(Kind::Custom("toast_card"))
180 .style_profile(StyleProfile::Surface)
181 .surface_role(SurfaceRole::Popover)
182 .axis(Axis::Row)
183 .align(Align::Stretch)
184 .gap(tokens::SPACE_2)
185 .padding(tokens::SPACE_3)
186 .fill(tokens::POPOVER)
187 .stroke(tokens::BORDER)
188 .radius(tokens::RADIUS_MD)
189 .shadow(tokens::SHADOW_MD)
190 .width(Size::Fixed(360.0))
191 .height(Size::Hug)
192 .children([lead, body, dismiss])
193}
194
195fn level_accent(level: ToastLevel) -> Color {
196 match level {
197 ToastLevel::Default => tokens::INPUT,
198 ToastLevel::Success => tokens::SUCCESS,
199 ToastLevel::Warning => tokens::WARNING,
200 ToastLevel::Error => tokens::DESTRUCTIVE,
201 ToastLevel::Info => tokens::INFO,
202 }
203}
204
205pub fn parse_dismiss_key(key: &str) -> Option<u64> {
209 key.strip_prefix("toast-dismiss-")
210 .and_then(|rest| rest.parse::<u64>().ok())
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use crate::layout::{assign_ids, layout};
217
218 #[test]
219 fn synthesize_appends_layer_per_active_toast() {
220 let mut tree = crate::stack(std::iter::empty::<El>());
221 let mut state = UiState::new();
222 let now = Instant::now();
223 state.push_toast(ToastSpec::success("Saved"), now);
224 state.push_toast(ToastSpec::error("Failed"), now);
225
226 assign_ids(&mut tree);
227 let pending = synthesize_toasts(&mut tree, &mut state, now);
228 assert!(pending, "active toasts → caller should request redraw");
229 let stack = tree.children.last().expect("toast_stack appended to root");
230 assert!(matches!(stack.kind, Kind::Custom("toast_stack")));
231 assert_eq!(stack.children.len(), 2);
232 }
233
234 #[test]
235 fn synthesize_drops_expired_toasts() {
236 let mut tree = crate::stack(std::iter::empty::<El>());
237 let mut state = UiState::new();
238 let t0 = Instant::now();
239 state.push_toast(
241 ToastSpec::info("old").with_ttl(Duration::from_millis(10)),
242 t0,
243 );
244 state.push_toast(ToastSpec::info("new").with_ttl(Duration::from_secs(60)), t0);
245 let later = t0 + Duration::from_secs(1);
246 let pending = synthesize_toasts(&mut tree, &mut state, later);
247 assert!(pending);
248 assert_eq!(state.toast.queue.len(), 1, "expired toast dropped");
249 assert_eq!(state.toast.queue[0].message, "new");
250 }
251
252 #[test]
253 fn synthesize_returns_false_when_no_toasts() {
254 let mut tree = crate::stack(std::iter::empty::<El>());
255 let mut state = UiState::new();
256 let pending = synthesize_toasts(&mut tree, &mut state, Instant::now());
257 assert!(!pending);
258 assert!(tree.children.is_empty());
259 }
260
261 #[test]
262 fn parse_dismiss_key_round_trip() {
263 assert_eq!(parse_dismiss_key("toast-dismiss-7"), Some(7));
264 assert_eq!(parse_dismiss_key("toast-dismiss-0"), Some(0));
265 assert_eq!(parse_dismiss_key("save"), None);
266 assert_eq!(parse_dismiss_key("toast-dismiss-abc"), None);
267 }
268
269 #[test]
270 fn toast_stack_layer_lays_out_at_root() {
271 let mut tree = crate::stack(std::iter::empty::<El>()).fill_size();
272 let mut state = UiState::new();
273 let now = Instant::now();
274 state.push_toast(ToastSpec::default("hello"), now);
275 synthesize_toasts(&mut tree, &mut state, now);
276 layout(&mut tree, &mut state, Rect::new(0.0, 0.0, 800.0, 600.0));
277 let stack = tree.children.last().unwrap();
280 let r = state.rect(&stack.computed_id);
281 assert!((r.w - 800.0).abs() < 0.01);
282 assert!((r.h - 600.0).abs() < 0.01);
283 }
284}