1use std::collections::VecDeque;
2use std::time::Instant;
3
4#[derive(Default)]
10pub struct StatusCenter {
11 pub(in crate::app) status: Option<StatusMessage>,
12 pub(in crate::app) toast: Option<StatusMessage>,
13 pub(in crate::app) toast_queue: VecDeque<StatusMessage>,
14}
15
16impl StatusCenter {
17 pub fn status(&self) -> Option<&StatusMessage> {
18 self.status.as_ref()
19 }
20
21 pub fn status_mut(&mut self) -> Option<&mut StatusMessage> {
22 self.status.as_mut()
23 }
24
25 pub fn toast(&self) -> Option<&StatusMessage> {
26 self.toast.as_ref()
27 }
28
29 pub fn toast_mut(&mut self) -> Option<&mut StatusMessage> {
30 self.toast.as_mut()
31 }
32
33 pub fn take_status(&mut self) -> Option<StatusMessage> {
34 self.status.take()
35 }
36
37 pub fn restore_status(&mut self, message: Option<StatusMessage>) {
38 self.status = message;
39 }
40
41 pub fn set_toast_message(&mut self, message: Option<StatusMessage>) {
42 self.toast = message;
43 }
44
45 pub fn drop_toasts_where(&mut self, mut matches: impl FnMut(&StatusMessage) -> bool) {
48 if self.toast.as_ref().is_some_and(&mut matches) {
49 self.toast = self.toast_queue.pop_front();
50 }
51 self.toast_queue.retain(|t| !matches(t));
52 }
53
54 pub fn set_status(&mut self, text: impl Into<String>, is_error: bool) {
55 let class = if is_error {
56 MessageClass::Error
57 } else {
58 MessageClass::Success
59 };
60 let sticky = matches!(class, MessageClass::Error);
62 let msg = StatusMessage {
63 text: text.into(),
64 class,
65 tick_count: 0,
66 sticky,
67 created_at: std::time::Instant::now(),
68 };
69 if msg.is_toast() {
70 self.push_toast(msg);
71 } else {
72 log::debug!("footer <- {:?}: {}", msg.class, msg.text);
73 self.status = Some(msg);
74 }
75 }
76
77 pub(crate) fn push_toast(&mut self, msg: StatusMessage) {
84 log::debug!("toast <- {:?}: {}", msg.class, msg.text);
85 if msg.class == MessageClass::Success {
86 self.toast = Some(msg);
87 self.toast_queue.clear();
88 return;
89 }
90 let active_blocks = self
92 .toast
93 .as_ref()
94 .is_some_and(|t| t.class != MessageClass::Success);
95 if active_blocks {
96 if self.toast_queue.len() >= crate::ui::design::TOAST_QUEUE_MAX {
97 if let Some(dropped) = self.toast_queue.front() {
98 log::debug!("toast queue full, dropping: {}", dropped.text);
99 }
100 self.toast_queue.pop_front();
101 }
102 self.toast_queue.push_back(msg);
103 } else {
104 if let Some(ref dropped) = self.toast {
105 log::debug!(
106 "toast promoted: replacing Success '{}' with {:?}",
107 dropped.text,
108 msg.class
109 );
110 }
111 self.toast = Some(msg);
112 }
113 }
114
115 pub fn set_info_status(&mut self, text: impl Into<String>) {
117 let text = text.into();
118 log::debug!("footer <- Info: {}", text);
119 self.status = Some(StatusMessage {
120 text,
121 class: MessageClass::Info,
122 tick_count: 0,
123 sticky: false,
124 created_at: std::time::Instant::now(),
125 });
126 }
127
128 pub fn set_background_status(&mut self, text: impl Into<String>, is_error: bool) {
133 if is_error {
134 let msg = StatusMessage {
135 text: text.into(),
136 class: MessageClass::Error,
137 tick_count: 0,
138 sticky: true,
139 created_at: std::time::Instant::now(),
140 };
141 self.push_toast(msg);
142 return;
143 }
144 let text = text.into();
145 if self.status.as_ref().is_some_and(|s| s.sticky) {
146 log::debug!(
147 "[purple] background status suppressed (sticky active, dropped: {})",
148 text
149 );
150 return;
151 }
152 log::debug!("footer <- Info: {}", text);
153 self.status = Some(StatusMessage {
154 text,
155 class: MessageClass::Info,
156 tick_count: 0,
157 sticky: false,
158 created_at: std::time::Instant::now(),
159 });
160 }
161
162 pub fn set_sticky_status(&mut self, text: impl Into<String>, is_error: bool) {
167 let text = text.into();
168 let class = if is_error {
169 MessageClass::Error
170 } else {
171 MessageClass::Progress
172 };
173 log::debug!("footer <- sticky {:?}: {}", class, text);
174 self.status = Some(StatusMessage {
175 text,
176 class,
177 tick_count: 0,
178 sticky: true,
179 created_at: std::time::Instant::now(),
180 });
181 }
182
183 pub(crate) fn clear_status(&mut self) {
188 if let Some(s) = &self.status {
189 log::debug!("footer <- clear: {}", s.text);
190 }
191 self.status = None;
192 }
193
194 pub fn clear_sticky_status(&mut self) {
199 if let Some(s) = &self.status {
200 if s.sticky {
201 log::debug!("footer <- clear sticky: {}", s.text);
202 self.status = None;
203 }
204 }
205 }
206
207 pub fn tick_toast(&mut self) {
211 if let Some(ref toast) = self.toast {
212 if toast.sticky {
213 return;
214 }
215 let timeout_ms = toast.timeout_ms();
216 if timeout_ms != u64::MAX && toast.created_at.elapsed().as_millis() as u64 > timeout_ms
217 {
218 log::debug!("toast expired: {}", toast.text);
219 self.toast = self.toast_queue.pop_front();
220 }
221 }
222 }
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum MessageClass {
233 Success,
236 Info,
239 Warning,
244 Error,
248 Progress,
251}
252
253#[derive(Debug, Clone)]
255pub struct StatusMessage {
256 pub text: String,
257 pub class: MessageClass,
258 #[allow(dead_code)]
261 pub tick_count: u32,
262 pub sticky: bool,
265 pub created_at: Instant,
269}
270
271impl StatusMessage {
272 pub fn is_error(&self) -> bool {
274 matches!(self.class, MessageClass::Error | MessageClass::Warning)
275 }
276
277 pub fn timeout_ms(&self) -> u64 {
289 let words = self
290 .text
291 .split_whitespace()
292 .count()
293 .min(crate::ui::design::WORD_CAP) as u64;
294 let proportional = words.saturating_mul(crate::ui::design::MS_PER_WORD);
295 let min_ms = match self.class {
296 MessageClass::Success | MessageClass::Info => crate::ui::design::TIMEOUT_MIN_MS,
297 MessageClass::Warning => crate::ui::design::TIMEOUT_MIN_WARNING_MS,
298 MessageClass::Error | MessageClass::Progress => return u64::MAX,
299 };
300 min_ms.max(proportional)
301 }
302
303 pub fn is_toast(&self) -> bool {
305 matches!(
306 self.class,
307 MessageClass::Success | MessageClass::Warning | MessageClass::Error
308 )
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 fn msg(text: &str, class: MessageClass, sticky: bool) -> StatusMessage {
317 StatusMessage {
318 text: text.to_string(),
319 class,
320 tick_count: 0,
321 sticky,
322 created_at: std::time::Instant::now(),
323 }
324 }
325
326 #[test]
327 fn default_is_quiet() {
328 let s = StatusCenter::default();
329 assert!(s.status.is_none());
330 assert!(s.toast.is_none());
331 assert!(s.toast_queue.is_empty());
332 }
333
334 #[test]
335 fn test_set_status_info_populates_status_field() {
336 let mut s = StatusCenter::default();
337 s.set_info_status("hello");
339 assert!(s.status.is_some());
340 assert_eq!(s.status.as_ref().unwrap().text, "hello");
341 assert!(s.toast.is_none());
342 }
343
344 #[test]
345 fn test_set_status_error_is_routed_to_sticky_toast() {
346 let mut s = StatusCenter::default();
347 s.set_status("boom", true);
348 assert!(s.toast.is_some());
350 let toast = s.toast.as_ref().unwrap();
351 assert_eq!(toast.class, MessageClass::Error);
352 assert!(toast.sticky);
353 }
354
355 #[test]
356 fn test_set_sticky_status_writes_footer_and_marks_sticky() {
357 let mut s = StatusCenter::default();
358 s.set_sticky_status("signing cert", false);
359 let footer = s.status.as_ref().expect("footer status set");
360 assert_eq!(footer.text, "signing cert");
361 assert_eq!(footer.class, MessageClass::Progress);
362 assert!(
363 footer.sticky,
364 "sticky progress message must stay until replaced"
365 );
366 assert!(s.toast.is_none());
368 }
369
370 #[test]
371 fn tick_toast_advances_queue_once_active_expires() {
372 let mut s = StatusCenter::default();
373 s.push_toast(msg("first", MessageClass::Warning, false));
375 s.push_toast(msg("second", MessageClass::Warning, false));
377 assert_eq!(s.toast.as_ref().unwrap().text, "first");
378 assert_eq!(s.toast_queue.len(), 1);
379
380 let expired_at = std::time::Instant::now()
383 .checked_sub(std::time::Duration::from_secs(60))
384 .expect("instant subtraction");
385 if let Some(active) = s.toast.as_mut() {
386 active.created_at = expired_at;
387 }
388 s.tick_toast();
389 assert_eq!(s.toast.as_ref().unwrap().text, "second");
391 assert!(s.toast_queue.is_empty());
392 }
393
394 #[test]
395 fn tick_toast_does_not_expire_sticky_toast() {
396 let mut s = StatusCenter::default();
397 s.push_toast(msg("stay", MessageClass::Error, true));
398 let expired_at = std::time::Instant::now()
399 .checked_sub(std::time::Duration::from_secs(3600))
400 .expect("instant subtraction");
401 if let Some(active) = s.toast.as_mut() {
402 active.created_at = expired_at;
403 }
404 s.tick_toast();
405 assert!(s.toast.is_some(), "sticky toast must not expire");
406 }
407
408 #[test]
409 fn clear_status_drops_active_footer_status() {
410 let mut s = StatusCenter::default();
411 s.set_info_status("syncing aws");
412 assert!(s.status.is_some());
413 s.clear_status();
414 assert!(s.status.is_none());
415 }
416
417 #[test]
418 fn clear_status_also_drops_sticky_footer_status() {
419 let mut s = StatusCenter::default();
422 s.set_sticky_status("signing cert", false);
423 assert!(s.status.as_ref().is_some_and(|m| m.sticky));
424 s.clear_status();
425 assert!(s.status.is_none());
426 }
427
428 #[test]
429 fn clear_status_on_empty_is_noop() {
430 let mut s = StatusCenter::default();
431 s.clear_status();
432 assert!(s.status.is_none());
433 assert!(s.toast.is_none());
434 }
435
436 #[test]
437 fn clear_status_does_not_touch_active_toast() {
438 let mut s = StatusCenter::default();
439 s.set_info_status("info");
440 s.push_toast(msg("warn", MessageClass::Warning, false));
441 s.clear_status();
442 assert!(s.status.is_none(), "footer cleared");
443 assert!(s.toast.is_some(), "toast slot untouched");
444 }
445}