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}