snora_core/toast.rs
1//! Toast notifications.
2//!
3//! A toast is a small, auto-stackable notification that appears anchored to
4//! one corner of the window. snora's toast contract carries **both** the
5//! visible payload (title, body, intent) and the **lifetime policy**,
6//! moving TTL management from user code into the framework.
7//!
8//! # Lifetime policy
9//!
10//! Each [`Toast`] declares a [`ToastLifetime`]:
11//!
12//! * [`ToastLifetime::Transient`] — the toast auto-dismisses after the
13//! given [`Duration`]. The engine provides a subscription helper that
14//! wakes the runtime periodically and the `snora::toast::sweep_expired`
15//! helper removes entries whose deadlines have passed.
16//! * [`ToastLifetime::Persistent`] — the toast remains until the user
17//! clicks the close button.
18//!
19//! # Design note — why does the toast own its creation time?
20//!
21//! Keeping `created_at` inside the struct, rather than outside in an
22//! auxiliary `expires_at` field, means the Toast is a self-describing unit:
23//! sweep logic is one pure function on a `Toast`, and test code can fabricate
24//! a toast with a specific creation time without touching any other state.
25
26use std::time::{Duration, Instant};
27
28/// Semantic intent of a notification.
29///
30/// Engines map intents to colors using the current theme. `Debug` is kept
31/// intentionally separate from `Info` so that diagnostic noise can be styled
32/// distinctly (or suppressed) without changing intent at every call site.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub enum ToastIntent {
35 /// Diagnostic information for developers. Lower priority than `Info`,
36 /// styled distinctly so that diagnostic noise can be visually separated
37 /// (or filtered out) without changing intent at every call site.
38 Debug,
39 /// Neutral information.
40 Info,
41 /// A positive outcome — completed action, saved file, sent message.
42 Success,
43 /// Something the user should notice but is not an error.
44 Warning,
45 /// A failure. Often paired with [`ToastLifetime::Persistent`] so the
46 /// user must acknowledge before the toast disappears.
47 Error,
48}
49
50impl std::fmt::Display for ToastIntent {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 let s = match self {
53 ToastIntent::Debug => "Debug",
54 ToastIntent::Info => "Info",
55 ToastIntent::Success => "Success",
56 ToastIntent::Warning => "Warning",
57 ToastIntent::Error => "Error",
58 };
59 f.write_str(s)
60 }
61}
62
63/// Where the toast stack anchors within the window.
64///
65/// Positions are expressed in **logical** terms (`Start` / `End`) along the
66/// horizontal axis, so they automatically mirror under
67/// [`crate::LayoutDirection::Rtl`] without per-application changes — the
68/// same ABDD principle that governs sidebars and header end-controls.
69///
70/// # Choosing a position
71///
72/// * [`TopEnd`] (default) — top-right under LTR, top-left under RTL.
73/// Recommended for application-internal notifications because the bottom
74/// half of the window is typically reserved for primary content
75/// (previews, editors, lists).
76/// * [`BottomEnd`] — bottom-right under LTR. Matches the OS-level
77/// notification center convention on macOS / GNOME / Windows.
78/// * [`TopStart`] / [`BottomStart`] — opposite horizontal edges from the
79/// `End` variants.
80/// * [`TopCenter`] / [`BottomCenter`] — centered horizontally. Useful for
81/// modal-feeling messages.
82///
83/// # Stack growth direction
84///
85/// New toasts are inserted so that the **most recent toast is closest to
86/// the anchor edge**:
87///
88/// * `Top*`: new toasts appear *below* older ones; the newest sits closest
89/// to the top edge.
90/// * `Bottom*`: new toasts appear *above* older ones; the newest sits
91/// closest to the bottom edge.
92///
93/// The engine is responsible for honoring this invariant; applications
94/// only push to the back of their `Vec<Toast<_>>` in chronological order.
95///
96/// [`TopEnd`]: ToastPosition::TopEnd
97/// [`BottomEnd`]: ToastPosition::BottomEnd
98/// [`TopStart`]: ToastPosition::TopStart
99/// [`BottomStart`]: ToastPosition::BottomStart
100/// [`TopCenter`]: ToastPosition::TopCenter
101/// [`BottomCenter`]: ToastPosition::BottomCenter
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
103pub enum ToastPosition {
104 /// LTR=top-right, RTL=top-left. The default.
105 #[default]
106 TopEnd,
107 /// LTR=top-left, RTL=top-right.
108 TopStart,
109 /// Horizontally centered, anchored to the top edge.
110 TopCenter,
111 /// LTR=bottom-right, RTL=bottom-left.
112 BottomEnd,
113 /// LTR=bottom-left, RTL=bottom-right.
114 BottomStart,
115 /// Horizontally centered, anchored to the bottom edge.
116 BottomCenter,
117}
118
119impl ToastPosition {
120 /// Whether this position anchors to the *top* edge of the window.
121 /// Engines use this to decide stack growth direction (top anchors grow
122 /// downward; bottom anchors grow upward).
123 ///
124 /// # Example
125 ///
126 /// ```
127 /// use snora_core::ToastPosition;
128 ///
129 /// assert!(ToastPosition::TopEnd.is_top());
130 /// assert!(ToastPosition::TopCenter.is_top());
131 /// assert!(!ToastPosition::BottomEnd.is_top());
132 /// ```
133 #[must_use]
134 pub fn is_top(self) -> bool {
135 matches!(
136 self,
137 ToastPosition::TopEnd | ToastPosition::TopStart | ToastPosition::TopCenter
138 )
139 }
140
141 /// Whether this position anchors to the *bottom* edge of the window.
142 ///
143 /// Always the inverse of [`Self::is_top`].
144 #[must_use]
145 pub fn is_bottom(self) -> bool {
146 !self.is_top()
147 }
148}
149
150/// Auto-dismiss policy for a toast.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum ToastLifetime {
153 /// Toast vanishes once `created_at + duration < now`.
154 Transient(Duration),
155 /// Toast stays until the user clicks the close button.
156 /// Use sparingly — reserved for errors that must be acknowledged.
157 Persistent,
158}
159
160impl ToastLifetime {
161 /// The default "normal channel" duration (4 seconds). Long enough to
162 /// read a short message, short enough not to stack up if the user is
163 /// busy with something else.
164 pub const DEFAULT: ToastLifetime = ToastLifetime::Transient(Duration::from_secs(4));
165
166 /// Convenience constructor for a transient lifetime in whole seconds.
167 #[must_use]
168 pub const fn seconds(secs: u64) -> Self {
169 ToastLifetime::Transient(Duration::from_secs(secs))
170 }
171
172 /// Convenience constructor for a transient lifetime in milliseconds.
173 #[must_use]
174 pub const fn millis(ms: u64) -> Self {
175 ToastLifetime::Transient(Duration::from_millis(ms))
176 }
177}
178
179/// A toast notification.
180///
181/// `Message` is your application's top-level message type. The `on_dismiss`
182/// field is fired when the user clicks the toast's close button. It is *not*
183/// fired when a transient toast expires; expiration is a silent sweep.
184#[derive(Debug, Clone)]
185pub struct Toast<Message: Clone> {
186 /// Application-assigned id. snora does not interpret or generate ids;
187 /// the application is the source of truth. Typically a monotonically
188 /// increasing `u64`.
189 pub id: u64,
190 /// Bold heading line — typically a few words.
191 pub title: String,
192 /// Body text — one or two short sentences, explaining the situation.
193 pub message: String,
194 /// Semantic category (`Info`, `Success`, `Warning`, …). Resolved to a
195 /// theme color by the engine.
196 pub intent: ToastIntent,
197 /// Auto-dismiss policy. Defaults to [`ToastLifetime::DEFAULT`]
198 /// (4-second transient).
199 pub lifetime: ToastLifetime,
200 /// When this toast was enqueued. Used with `lifetime` to compute
201 /// expiration.
202 pub created_at: Instant,
203 /// Emitted when the user clicks the close button.
204 pub on_dismiss: Message,
205}
206
207impl<Message: Clone> Toast<Message> {
208 /// Build a new toast with `created_at` set to [`Instant::now()`].
209 ///
210 /// This constructor takes the mandatory fields positionally and uses
211 /// [`ToastLifetime::DEFAULT`] for the lifetime. Use builder-style
212 /// methods below to customize further.
213 ///
214 /// # Example
215 ///
216 /// ```
217 /// use snora_core::{Toast, ToastIntent};
218 ///
219 /// #[derive(Clone, Debug)]
220 /// enum Msg { DismissToast(u64) }
221 ///
222 /// let toast = Toast::new(
223 /// /* id */ 1,
224 /// /* intent */ ToastIntent::Success,
225 /// /* title */ "Saved",
226 /// /* message */ "Your changes are stored.",
227 /// /* on_dismiss */ Msg::DismissToast(1),
228 /// );
229 /// assert_eq!(toast.id, 1);
230 /// assert_eq!(toast.title, "Saved");
231 /// assert_eq!(toast.intent, ToastIntent::Success);
232 /// ```
233 pub fn new(
234 id: u64,
235 intent: ToastIntent,
236 title: impl Into<String>,
237 message: impl Into<String>,
238 on_dismiss: Message,
239 ) -> Self {
240 Self {
241 id,
242 title: title.into(),
243 message: message.into(),
244 intent,
245 lifetime: ToastLifetime::DEFAULT,
246 created_at: Instant::now(),
247 on_dismiss,
248 }
249 }
250
251 /// Override the lifetime.
252 #[must_use]
253 pub fn with_lifetime(mut self, lifetime: ToastLifetime) -> Self {
254 self.lifetime = lifetime;
255 self
256 }
257
258 /// Make this toast persistent (never auto-dismiss).
259 ///
260 /// Use for notifications the user must explicitly acknowledge —
261 /// "Export complete", "Disk almost full", confirmations of long
262 /// operations.
263 ///
264 /// # Example
265 ///
266 /// ```
267 /// use snora_core::{Toast, ToastIntent, ToastLifetime};
268 ///
269 /// #[derive(Clone, Debug)]
270 /// enum Msg { DismissToast(u64) }
271 ///
272 /// let toast = Toast::new(
273 /// 1, ToastIntent::Info, "Exported", "Done.", Msg::DismissToast(1),
274 /// )
275 /// .persistent();
276 /// assert_eq!(toast.lifetime, ToastLifetime::Persistent);
277 /// ```
278 #[must_use]
279 pub fn persistent(mut self) -> Self {
280 self.lifetime = ToastLifetime::Persistent;
281 self
282 }
283
284 /// Override the creation timestamp. Mainly useful for tests.
285 #[must_use]
286 pub fn with_created_at(mut self, created_at: Instant) -> Self {
287 self.created_at = created_at;
288 self
289 }
290
291 /// True when this toast has outlived its transient deadline.
292 /// Persistent toasts always return `false`.
293 #[must_use]
294 pub fn is_expired(&self, now: Instant) -> bool {
295 match self.lifetime {
296 ToastLifetime::Persistent => false,
297 ToastLifetime::Transient(d) => now.saturating_duration_since(self.created_at) >= d,
298 }
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn persistent_never_expires() {
308 let t = Toast::new(1, ToastIntent::Info, "t", "m", ()).persistent();
309 assert!(!t.is_expired(Instant::now() + Duration::from_secs(3600)));
310 }
311
312 #[test]
313 fn transient_expires_past_deadline() {
314 let base = Instant::now();
315 let t = Toast::new(1, ToastIntent::Info, "t", "m", ())
316 .with_lifetime(ToastLifetime::millis(100))
317 .with_created_at(base);
318 assert!(!t.is_expired(base));
319 assert!(!t.is_expired(base + Duration::from_millis(50)));
320 assert!(t.is_expired(base + Duration::from_millis(100)));
321 assert!(t.is_expired(base + Duration::from_millis(200)));
322 }
323
324 #[test]
325 fn default_toast_position_is_top_end() {
326 assert_eq!(ToastPosition::default(), ToastPosition::TopEnd);
327 }
328
329 #[test]
330 fn top_positions_classify_as_top() {
331 assert!(ToastPosition::TopEnd.is_top());
332 assert!(ToastPosition::TopStart.is_top());
333 assert!(ToastPosition::TopCenter.is_top());
334 assert!(!ToastPosition::BottomEnd.is_top());
335 assert!(!ToastPosition::BottomStart.is_top());
336 assert!(!ToastPosition::BottomCenter.is_top());
337 }
338
339 #[test]
340 fn is_top_and_is_bottom_partition() {
341 for pos in [
342 ToastPosition::TopEnd,
343 ToastPosition::TopStart,
344 ToastPosition::TopCenter,
345 ToastPosition::BottomEnd,
346 ToastPosition::BottomStart,
347 ToastPosition::BottomCenter,
348 ] {
349 assert_ne!(pos.is_top(), pos.is_bottom());
350 }
351 }
352}