Skip to main content

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}