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    Debug,
36    Info,
37    Success,
38    Warning,
39    Error,
40}
41
42impl std::fmt::Display for ToastIntent {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        let s = match self {
45            ToastIntent::Debug => "Debug",
46            ToastIntent::Info => "Info",
47            ToastIntent::Success => "Success",
48            ToastIntent::Warning => "Warning",
49            ToastIntent::Error => "Error",
50        };
51        f.write_str(s)
52    }
53}
54
55/// Auto-dismiss policy for a toast.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum ToastLifetime {
58    /// Toast vanishes once `created_at + duration < now`.
59    Transient(Duration),
60    /// Toast stays until the user clicks the close button.
61    /// Use sparingly — reserved for errors that must be acknowledged.
62    Persistent,
63}
64
65impl ToastLifetime {
66    /// The default "normal channel" duration (4 seconds). Long enough to
67    /// read a short message, short enough not to stack up if the user is
68    /// busy with something else.
69    pub const DEFAULT: ToastLifetime = ToastLifetime::Transient(Duration::from_secs(4));
70
71    /// Convenience constructor for a transient lifetime in whole seconds.
72    #[must_use]
73    pub const fn seconds(secs: u64) -> Self {
74        ToastLifetime::Transient(Duration::from_secs(secs))
75    }
76
77    /// Convenience constructor for a transient lifetime in milliseconds.
78    #[must_use]
79    pub const fn millis(ms: u64) -> Self {
80        ToastLifetime::Transient(Duration::from_millis(ms))
81    }
82}
83
84/// A toast notification.
85///
86/// `Message` is your application's top-level message type. The `on_dismiss`
87/// field is fired when the user clicks the toast's close button. It is *not*
88/// fired when a transient toast expires; expiration is a silent sweep.
89#[derive(Debug, Clone)]
90pub struct Toast<Message: Clone> {
91    /// Application-assigned id. snora does not interpret or generate ids;
92    /// the application is the source of truth. Typically a monotonically
93    /// increasing `u64`.
94    pub id: u64,
95    pub title: String,
96    pub message: String,
97    pub intent: ToastIntent,
98    pub lifetime: ToastLifetime,
99    /// When this toast was enqueued. Used with `lifetime` to compute
100    /// expiration.
101    pub created_at: Instant,
102    /// Emitted when the user clicks the close button.
103    pub on_dismiss: Message,
104}
105
106impl<Message: Clone> Toast<Message> {
107    /// Build a new toast with `created_at` set to [`Instant::now()`].
108    ///
109    /// This constructor takes the mandatory fields positionally and uses
110    /// [`ToastLifetime::DEFAULT`] for the lifetime. Use builder-style
111    /// methods below to customize further.
112    pub fn new(
113        id: u64,
114        intent: ToastIntent,
115        title: impl Into<String>,
116        message: impl Into<String>,
117        on_dismiss: Message,
118    ) -> Self {
119        Self {
120            id,
121            title: title.into(),
122            message: message.into(),
123            intent,
124            lifetime: ToastLifetime::DEFAULT,
125            created_at: Instant::now(),
126            on_dismiss,
127        }
128    }
129
130    /// Override the lifetime.
131    #[must_use]
132    pub fn with_lifetime(mut self, lifetime: ToastLifetime) -> Self {
133        self.lifetime = lifetime;
134        self
135    }
136
137    /// Make this toast persistent (never auto-dismiss).
138    #[must_use]
139    pub fn persistent(mut self) -> Self {
140        self.lifetime = ToastLifetime::Persistent;
141        self
142    }
143
144    /// Override the creation timestamp. Mainly useful for tests.
145    #[must_use]
146    pub fn with_created_at(mut self, created_at: Instant) -> Self {
147        self.created_at = created_at;
148        self
149    }
150
151    /// True when this toast has outlived its transient deadline.
152    /// Persistent toasts always return `false`.
153    #[must_use]
154    pub fn is_expired(&self, now: Instant) -> bool {
155        match self.lifetime {
156            ToastLifetime::Persistent => false,
157            ToastLifetime::Transient(d) => now.saturating_duration_since(self.created_at) >= d,
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn persistent_never_expires() {
168        let t = Toast::new(1, ToastIntent::Info, "t", "m", ()).persistent();
169        assert!(!t.is_expired(Instant::now() + Duration::from_secs(3600)));
170    }
171
172    #[test]
173    fn transient_expires_past_deadline() {
174        let base = Instant::now();
175        let t = Toast::new(1, ToastIntent::Info, "t", "m", ())
176            .with_lifetime(ToastLifetime::millis(100))
177            .with_created_at(base);
178        assert!(!t.is_expired(base));
179        assert!(!t.is_expired(base + Duration::from_millis(50)));
180        assert!(t.is_expired(base + Duration::from_millis(100)));
181        assert!(t.is_expired(base + Duration::from_millis(200)));
182    }
183}