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(crate) fn clear_status(&mut self) {
151 if let Some(s) = &self.status {
152 log::debug!("footer <- clear: {}", s.text);
153 }
154 self.status = None;
155 }
156
157 pub fn clear_sticky_status(&mut self) {
162 if let Some(s) = &self.status {
163 if s.sticky {
164 log::debug!("footer <- clear sticky: {}", s.text);
165 self.status = None;
166 }
167 }
168 }
169
170 pub fn tick_toast(&mut self) {
174 if let Some(ref toast) = self.toast {
175 if toast.sticky {
176 return;
177 }
178 let timeout_ms = toast.timeout_ms();
179 if timeout_ms != u64::MAX && toast.created_at.elapsed().as_millis() as u64 > timeout_ms
180 {
181 log::debug!("toast expired: {}", toast.text);
182 self.toast = self.toast_queue.pop_front();
183 }
184 }
185 }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum MessageClass {
196 Success,
199 Info,
202 Warning,
207 Error,
211 Progress,
214}
215
216#[derive(Debug, Clone)]
218pub struct StatusMessage {
219 pub text: String,
220 pub class: MessageClass,
221 #[allow(dead_code)]
224 pub tick_count: u32,
225 pub sticky: bool,
228 pub created_at: Instant,
232}
233
234impl StatusMessage {
235 pub fn is_error(&self) -> bool {
237 matches!(self.class, MessageClass::Error | MessageClass::Warning)
238 }
239
240 pub fn timeout_ms(&self) -> u64 {
252 let words = self
253 .text
254 .split_whitespace()
255 .count()
256 .min(crate::ui::design::WORD_CAP) as u64;
257 let proportional = words.saturating_mul(crate::ui::design::MS_PER_WORD);
258 let min_ms = match self.class {
259 MessageClass::Success | MessageClass::Info => crate::ui::design::TIMEOUT_MIN_MS,
260 MessageClass::Warning => crate::ui::design::TIMEOUT_MIN_WARNING_MS,
261 MessageClass::Error | MessageClass::Progress => return u64::MAX,
262 };
263 min_ms.max(proportional)
264 }
265
266 pub fn is_toast(&self) -> bool {
268 matches!(
269 self.class,
270 MessageClass::Success | MessageClass::Warning | MessageClass::Error
271 )
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 fn msg(text: &str, class: MessageClass, sticky: bool) -> StatusMessage {
280 StatusMessage {
281 text: text.to_string(),
282 class,
283 tick_count: 0,
284 sticky,
285 created_at: std::time::Instant::now(),
286 }
287 }
288
289 #[test]
290 fn default_is_quiet() {
291 let s = StatusCenter::default();
292 assert!(s.status.is_none());
293 assert!(s.toast.is_none());
294 assert!(s.toast_queue.is_empty());
295 }
296
297 #[test]
298 fn test_set_status_info_populates_status_field() {
299 let mut s = StatusCenter::default();
300 s.set_info_status("hello");
302 assert!(s.status.is_some());
303 assert_eq!(s.status.as_ref().unwrap().text, "hello");
304 assert!(s.toast.is_none());
305 }
306
307 #[test]
308 fn test_set_status_error_is_routed_to_sticky_toast() {
309 let mut s = StatusCenter::default();
310 s.set_status("boom", true);
311 assert!(s.toast.is_some());
313 let toast = s.toast.as_ref().unwrap();
314 assert_eq!(toast.class, MessageClass::Error);
315 assert!(toast.sticky);
316 }
317
318 #[test]
319 fn test_set_sticky_status_writes_footer_and_marks_sticky() {
320 let mut s = StatusCenter::default();
321 s.set_sticky_status("signing cert", false);
322 let footer = s.status.as_ref().expect("footer status set");
323 assert_eq!(footer.text, "signing cert");
324 assert_eq!(footer.class, MessageClass::Progress);
325 assert!(
326 footer.sticky,
327 "sticky progress message must stay until replaced"
328 );
329 assert!(s.toast.is_none());
331 }
332
333 #[test]
334 fn tick_toast_advances_queue_once_active_expires() {
335 let mut s = StatusCenter::default();
336 s.push_toast(msg("first", MessageClass::Warning, false));
338 s.push_toast(msg("second", MessageClass::Warning, false));
340 assert_eq!(s.toast.as_ref().unwrap().text, "first");
341 assert_eq!(s.toast_queue.len(), 1);
342
343 let expired_at = std::time::Instant::now()
346 .checked_sub(std::time::Duration::from_secs(60))
347 .expect("instant subtraction");
348 if let Some(active) = s.toast.as_mut() {
349 active.created_at = expired_at;
350 }
351 s.tick_toast();
352 assert_eq!(s.toast.as_ref().unwrap().text, "second");
354 assert!(s.toast_queue.is_empty());
355 }
356
357 #[test]
358 fn tick_toast_does_not_expire_sticky_toast() {
359 let mut s = StatusCenter::default();
360 s.push_toast(msg("stay", MessageClass::Error, true));
361 let expired_at = std::time::Instant::now()
362 .checked_sub(std::time::Duration::from_secs(3600))
363 .expect("instant subtraction");
364 if let Some(active) = s.toast.as_mut() {
365 active.created_at = expired_at;
366 }
367 s.tick_toast();
368 assert!(s.toast.is_some(), "sticky toast must not expire");
369 }
370
371 #[test]
372 fn clear_status_drops_active_footer_status() {
373 let mut s = StatusCenter::default();
374 s.set_info_status("syncing aws");
375 assert!(s.status.is_some());
376 s.clear_status();
377 assert!(s.status.is_none());
378 }
379
380 #[test]
381 fn clear_status_also_drops_sticky_footer_status() {
382 let mut s = StatusCenter::default();
385 s.set_sticky_status("signing cert", false);
386 assert!(s.status.as_ref().is_some_and(|m| m.sticky));
387 s.clear_status();
388 assert!(s.status.is_none());
389 }
390
391 #[test]
392 fn clear_status_on_empty_is_noop() {
393 let mut s = StatusCenter::default();
394 s.clear_status();
395 assert!(s.status.is_none());
396 assert!(s.toast.is_none());
397 }
398
399 #[test]
400 fn clear_status_does_not_touch_active_toast() {
401 let mut s = StatusCenter::default();
402 s.set_info_status("info");
403 s.push_toast(msg("warn", MessageClass::Warning, false));
404 s.clear_status();
405 assert!(s.status.is_none(), "footer cleared");
406 assert!(s.toast.is_some(), "toast slot untouched");
407 }
408}