1use std::collections::VecDeque;
2use std::time::Instant;
3
4#[derive(Default)]
10pub struct StatusCenter {
11 pub status: Option<StatusMessage>,
12 pub toast: Option<StatusMessage>,
13 pub toast_queue: VecDeque<StatusMessage>,
14}
15
16impl StatusCenter {
17 pub fn set_status(&mut self, text: impl Into<String>, is_error: bool) {
18 let class = if is_error {
19 MessageClass::Error
20 } else {
21 MessageClass::Success
22 };
23 let sticky = matches!(class, MessageClass::Error);
25 let msg = StatusMessage {
26 text: text.into(),
27 class,
28 tick_count: 0,
29 sticky,
30 created_at: std::time::Instant::now(),
31 };
32 if msg.is_toast() {
33 self.push_toast(msg);
34 } else {
35 log::debug!("footer <- {:?}: {}", msg.class, msg.text);
36 self.status = Some(msg);
37 }
38 }
39
40 pub(crate) fn push_toast(&mut self, msg: StatusMessage) {
47 log::debug!("toast <- {:?}: {}", msg.class, msg.text);
48 if msg.class == MessageClass::Success {
49 self.toast = Some(msg);
50 self.toast_queue.clear();
51 return;
52 }
53 let active_blocks = self
55 .toast
56 .as_ref()
57 .is_some_and(|t| t.class != MessageClass::Success);
58 if active_blocks {
59 if self.toast_queue.len() >= crate::ui::design::TOAST_QUEUE_MAX {
60 if let Some(dropped) = self.toast_queue.front() {
61 log::debug!("toast queue full, dropping: {}", dropped.text);
62 }
63 self.toast_queue.pop_front();
64 }
65 self.toast_queue.push_back(msg);
66 } else {
67 if let Some(ref dropped) = self.toast {
68 log::debug!(
69 "toast promoted: replacing Success '{}' with {:?}",
70 dropped.text,
71 msg.class
72 );
73 }
74 self.toast = Some(msg);
75 }
76 }
77
78 pub fn set_info_status(&mut self, text: impl Into<String>) {
80 let text = text.into();
81 log::debug!("footer <- Info: {}", text);
82 self.status = Some(StatusMessage {
83 text,
84 class: MessageClass::Info,
85 tick_count: 0,
86 sticky: false,
87 created_at: std::time::Instant::now(),
88 });
89 }
90
91 pub fn set_background_status(&mut self, text: impl Into<String>, is_error: bool) {
96 if is_error {
97 let msg = StatusMessage {
98 text: text.into(),
99 class: MessageClass::Error,
100 tick_count: 0,
101 sticky: true,
102 created_at: std::time::Instant::now(),
103 };
104 self.push_toast(msg);
105 return;
106 }
107 let text = text.into();
108 if self.status.as_ref().is_some_and(|s| s.sticky) {
109 log::debug!(
110 "[purple] background status suppressed (sticky active, dropped: {})",
111 text
112 );
113 return;
114 }
115 log::debug!("footer <- Info: {}", text);
116 self.status = Some(StatusMessage {
117 text,
118 class: MessageClass::Info,
119 tick_count: 0,
120 sticky: false,
121 created_at: std::time::Instant::now(),
122 });
123 }
124
125 pub fn set_sticky_status(&mut self, text: impl Into<String>, is_error: bool) {
130 let text = text.into();
131 let class = if is_error {
132 MessageClass::Error
133 } else {
134 MessageClass::Progress
135 };
136 log::debug!("footer <- sticky {:?}: {}", class, text);
137 self.status = Some(StatusMessage {
138 text,
139 class,
140 tick_count: 0,
141 sticky: true,
142 created_at: std::time::Instant::now(),
143 });
144 }
145
146 pub fn clear_sticky_status(&mut self) {
151 if let Some(s) = &self.status {
152 if s.sticky {
153 log::debug!("footer <- clear sticky: {}", s.text);
154 self.status = None;
155 }
156 }
157 }
158
159 pub fn tick_toast(&mut self) {
163 if let Some(ref toast) = self.toast {
164 if toast.sticky {
165 return;
166 }
167 let timeout_ms = toast.timeout_ms();
168 if timeout_ms != u64::MAX && toast.created_at.elapsed().as_millis() as u64 > timeout_ms
169 {
170 log::debug!("toast expired: {}", toast.text);
171 self.toast = self.toast_queue.pop_front();
172 }
173 }
174 }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum MessageClass {
185 Success,
188 Info,
191 Warning,
196 Error,
200 Progress,
203}
204
205#[derive(Debug, Clone)]
207pub struct StatusMessage {
208 pub text: String,
209 pub class: MessageClass,
210 #[allow(dead_code)]
213 pub tick_count: u32,
214 pub sticky: bool,
217 pub created_at: Instant,
221}
222
223impl StatusMessage {
224 pub fn is_error(&self) -> bool {
226 matches!(self.class, MessageClass::Error | MessageClass::Warning)
227 }
228
229 pub fn timeout_ms(&self) -> u64 {
241 let words = self
242 .text
243 .split_whitespace()
244 .count()
245 .min(crate::ui::design::WORD_CAP) as u64;
246 let proportional = words.saturating_mul(crate::ui::design::MS_PER_WORD);
247 let min_ms = match self.class {
248 MessageClass::Success | MessageClass::Info => crate::ui::design::TIMEOUT_MIN_MS,
249 MessageClass::Warning => crate::ui::design::TIMEOUT_MIN_WARNING_MS,
250 MessageClass::Error | MessageClass::Progress => return u64::MAX,
251 };
252 min_ms.max(proportional)
253 }
254
255 pub fn is_toast(&self) -> bool {
257 matches!(
258 self.class,
259 MessageClass::Success | MessageClass::Warning | MessageClass::Error
260 )
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 fn msg(text: &str, class: MessageClass, sticky: bool) -> StatusMessage {
269 StatusMessage {
270 text: text.to_string(),
271 class,
272 tick_count: 0,
273 sticky,
274 created_at: std::time::Instant::now(),
275 }
276 }
277
278 #[test]
279 fn default_is_quiet() {
280 let s = StatusCenter::default();
281 assert!(s.status.is_none());
282 assert!(s.toast.is_none());
283 assert!(s.toast_queue.is_empty());
284 }
285
286 #[test]
287 fn test_set_status_info_populates_status_field() {
288 let mut s = StatusCenter::default();
289 s.set_info_status("hello");
291 assert!(s.status.is_some());
292 assert_eq!(s.status.as_ref().unwrap().text, "hello");
293 assert!(s.toast.is_none());
294 }
295
296 #[test]
297 fn test_set_status_error_is_routed_to_sticky_toast() {
298 let mut s = StatusCenter::default();
299 s.set_status("boom", true);
300 assert!(s.toast.is_some());
302 let toast = s.toast.as_ref().unwrap();
303 assert_eq!(toast.class, MessageClass::Error);
304 assert!(toast.sticky);
305 }
306
307 #[test]
308 fn test_set_sticky_status_writes_footer_and_marks_sticky() {
309 let mut s = StatusCenter::default();
310 s.set_sticky_status("signing cert", false);
311 let footer = s.status.as_ref().expect("footer status set");
312 assert_eq!(footer.text, "signing cert");
313 assert_eq!(footer.class, MessageClass::Progress);
314 assert!(
315 footer.sticky,
316 "sticky progress message must stay until replaced"
317 );
318 assert!(s.toast.is_none());
320 }
321
322 #[test]
323 fn tick_toast_advances_queue_once_active_expires() {
324 let mut s = StatusCenter::default();
325 s.push_toast(msg("first", MessageClass::Warning, false));
327 s.push_toast(msg("second", MessageClass::Warning, false));
329 assert_eq!(s.toast.as_ref().unwrap().text, "first");
330 assert_eq!(s.toast_queue.len(), 1);
331
332 let expired_at = std::time::Instant::now()
335 .checked_sub(std::time::Duration::from_secs(60))
336 .expect("instant subtraction");
337 if let Some(active) = s.toast.as_mut() {
338 active.created_at = expired_at;
339 }
340 s.tick_toast();
341 assert_eq!(s.toast.as_ref().unwrap().text, "second");
343 assert!(s.toast_queue.is_empty());
344 }
345
346 #[test]
347 fn tick_toast_does_not_expire_sticky_toast() {
348 let mut s = StatusCenter::default();
349 s.push_toast(msg("stay", MessageClass::Error, true));
350 let expired_at = std::time::Instant::now()
351 .checked_sub(std::time::Duration::from_secs(3600))
352 .expect("instant subtraction");
353 if let Some(active) = s.toast.as_mut() {
354 active.created_at = expired_at;
355 }
356 s.tick_toast();
357 assert!(s.toast.is_some(), "sticky toast must not expire");
358 }
359}