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(crate) fn notify(&mut self, text: impl Into<String>) {
57 self.set_status(text, false);
58 }
59
60 pub(crate) fn notify_error(&mut self, text: impl Into<String>) {
62 self.set_status(text, true);
63 }
64
65 pub fn set_status(&mut self, text: impl Into<String>, is_error: bool) {
66 let class = if is_error {
67 MessageClass::Error
68 } else {
69 MessageClass::Success
70 };
71 let sticky = matches!(class, MessageClass::Error);
73 let msg = StatusMessage {
74 text: text.into(),
75 class,
76 tick_count: 0,
77 sticky,
78 created_at: std::time::Instant::now(),
79 };
80 if msg.is_toast() {
81 self.push_toast(msg);
82 } else {
83 log::debug!("footer <- {:?}: {}", msg.class, msg.text);
84 self.status = Some(msg);
85 }
86 }
87
88 pub(crate) fn push_toast(&mut self, msg: StatusMessage) {
95 log::debug!("toast <- {:?}: {}", msg.class, msg.text);
96 if msg.class == MessageClass::Success {
97 self.toast = Some(msg);
98 self.toast_queue.clear();
99 return;
100 }
101 let active_blocks = self
103 .toast
104 .as_ref()
105 .is_some_and(|t| t.class != MessageClass::Success);
106 if active_blocks {
107 if self.toast_queue.len() >= crate::ui::design::TOAST_QUEUE_MAX {
108 if let Some(dropped) = self.toast_queue.front() {
109 log::debug!("toast queue full, dropping: {}", dropped.text);
110 }
111 self.toast_queue.pop_front();
112 }
113 self.toast_queue.push_back(msg);
114 } else {
115 if let Some(ref dropped) = self.toast {
116 log::debug!(
117 "toast promoted: replacing Success '{}' with {:?}",
118 dropped.text,
119 msg.class
120 );
121 }
122 self.toast = Some(msg);
123 }
124 }
125
126 pub fn set_info_status(&mut self, text: impl Into<String>) {
128 let text = text.into();
129 log::debug!("footer <- Info: {}", text);
130 self.status = Some(StatusMessage {
131 text,
132 class: MessageClass::Info,
133 tick_count: 0,
134 sticky: false,
135 created_at: std::time::Instant::now(),
136 });
137 }
138
139 pub fn set_background_status(&mut self, text: impl Into<String>, is_error: bool) {
144 if is_error {
145 let msg = StatusMessage {
146 text: text.into(),
147 class: MessageClass::Error,
148 tick_count: 0,
149 sticky: true,
150 created_at: std::time::Instant::now(),
151 };
152 self.push_toast(msg);
153 return;
154 }
155 let text = text.into();
156 if self.status.as_ref().is_some_and(|s| s.sticky) {
157 log::debug!(
158 "[purple] background status suppressed (sticky active, dropped: {})",
159 text
160 );
161 return;
162 }
163 log::debug!("footer <- Info: {}", text);
164 self.status = Some(StatusMessage {
165 text,
166 class: MessageClass::Info,
167 tick_count: 0,
168 sticky: false,
169 created_at: std::time::Instant::now(),
170 });
171 }
172
173 pub fn set_sticky_status(&mut self, text: impl Into<String>, is_error: bool) {
178 let text = text.into();
179 let class = if is_error {
180 MessageClass::Error
181 } else {
182 MessageClass::Progress
183 };
184 log::debug!("footer <- sticky {:?}: {}", class, text);
185 self.status = Some(StatusMessage {
186 text,
187 class,
188 tick_count: 0,
189 sticky: true,
190 created_at: std::time::Instant::now(),
191 });
192 }
193
194 pub(crate) fn clear_status(&mut self) {
199 if let Some(s) = &self.status {
200 log::debug!("footer <- clear: {}", s.text);
201 }
202 self.status = None;
203 }
204
205 pub fn clear_sticky_status(&mut self) {
210 if let Some(s) = &self.status {
211 if s.sticky {
212 log::debug!("footer <- clear sticky: {}", s.text);
213 self.status = None;
214 }
215 }
216 }
217
218 pub fn tick_toast(&mut self) {
222 if let Some(ref toast) = self.toast {
223 if toast.sticky {
224 return;
225 }
226 let timeout_ms = toast.timeout_ms();
227 if timeout_ms != u64::MAX && toast.created_at.elapsed().as_millis() as u64 > timeout_ms
228 {
229 log::debug!("toast expired: {}", toast.text);
230 self.toast = self.toast_queue.pop_front();
231 }
232 }
233 }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum MessageClass {
244 Success,
247 Info,
250 Warning,
255 Error,
259 Progress,
262}
263
264#[derive(Debug, Clone)]
266pub struct StatusMessage {
267 pub text: String,
268 pub class: MessageClass,
269 #[allow(dead_code)]
272 pub tick_count: u32,
273 pub sticky: bool,
276 pub created_at: Instant,
280}
281
282impl StatusMessage {
283 pub fn is_error(&self) -> bool {
285 matches!(self.class, MessageClass::Error | MessageClass::Warning)
286 }
287
288 pub fn timeout_ms(&self) -> u64 {
300 let words = self
301 .text
302 .split_whitespace()
303 .count()
304 .min(crate::ui::design::WORD_CAP) as u64;
305 let proportional = words.saturating_mul(crate::ui::design::MS_PER_WORD);
306 let min_ms = match self.class {
307 MessageClass::Success | MessageClass::Info => crate::ui::design::TIMEOUT_MIN_MS,
308 MessageClass::Warning => crate::ui::design::TIMEOUT_MIN_WARNING_MS,
309 MessageClass::Error | MessageClass::Progress => return u64::MAX,
310 };
311 min_ms.max(proportional)
312 }
313
314 pub fn is_toast(&self) -> bool {
316 matches!(
317 self.class,
318 MessageClass::Success | MessageClass::Warning | MessageClass::Error
319 )
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 fn msg(text: &str, class: MessageClass, sticky: bool) -> StatusMessage {
328 StatusMessage {
329 text: text.to_string(),
330 class,
331 tick_count: 0,
332 sticky,
333 created_at: std::time::Instant::now(),
334 }
335 }
336
337 #[test]
338 fn default_is_quiet() {
339 let s = StatusCenter::default();
340 assert!(s.status.is_none());
341 assert!(s.toast.is_none());
342 assert!(s.toast_queue.is_empty());
343 }
344
345 #[test]
346 fn test_set_status_info_populates_status_field() {
347 let mut s = StatusCenter::default();
348 s.set_info_status("hello");
350 assert!(s.status.is_some());
351 assert_eq!(s.status.as_ref().unwrap().text, "hello");
352 assert!(s.toast.is_none());
353 }
354
355 #[test]
356 fn test_set_status_error_is_routed_to_sticky_toast() {
357 let mut s = StatusCenter::default();
358 s.set_status("boom", true);
359 assert!(s.toast.is_some());
361 let toast = s.toast.as_ref().unwrap();
362 assert_eq!(toast.class, MessageClass::Error);
363 assert!(toast.sticky);
364 }
365
366 #[test]
367 fn test_set_sticky_status_writes_footer_and_marks_sticky() {
368 let mut s = StatusCenter::default();
369 s.set_sticky_status("signing cert", false);
370 let footer = s.status.as_ref().expect("footer status set");
371 assert_eq!(footer.text, "signing cert");
372 assert_eq!(footer.class, MessageClass::Progress);
373 assert!(
374 footer.sticky,
375 "sticky progress message must stay until replaced"
376 );
377 assert!(s.toast.is_none());
379 }
380
381 #[test]
382 fn tick_toast_advances_queue_once_active_expires() {
383 let mut s = StatusCenter::default();
384 s.push_toast(msg("first", MessageClass::Warning, false));
386 s.push_toast(msg("second", MessageClass::Warning, false));
388 assert_eq!(s.toast.as_ref().unwrap().text, "first");
389 assert_eq!(s.toast_queue.len(), 1);
390
391 let expired_at = std::time::Instant::now()
394 .checked_sub(std::time::Duration::from_secs(60))
395 .expect("instant subtraction");
396 if let Some(active) = s.toast.as_mut() {
397 active.created_at = expired_at;
398 }
399 s.tick_toast();
400 assert_eq!(s.toast.as_ref().unwrap().text, "second");
402 assert!(s.toast_queue.is_empty());
403 }
404
405 #[test]
406 fn tick_toast_does_not_expire_sticky_toast() {
407 let mut s = StatusCenter::default();
408 s.push_toast(msg("stay", MessageClass::Error, true));
409 let expired_at = std::time::Instant::now()
410 .checked_sub(std::time::Duration::from_secs(3600))
411 .expect("instant subtraction");
412 if let Some(active) = s.toast.as_mut() {
413 active.created_at = expired_at;
414 }
415 s.tick_toast();
416 assert!(s.toast.is_some(), "sticky toast must not expire");
417 }
418
419 #[test]
420 fn clear_status_drops_active_footer_status() {
421 let mut s = StatusCenter::default();
422 s.set_info_status("syncing aws");
423 assert!(s.status.is_some());
424 s.clear_status();
425 assert!(s.status.is_none());
426 }
427
428 #[test]
429 fn clear_status_also_drops_sticky_footer_status() {
430 let mut s = StatusCenter::default();
433 s.set_sticky_status("signing cert", false);
434 assert!(s.status.as_ref().is_some_and(|m| m.sticky));
435 s.clear_status();
436 assert!(s.status.is_none());
437 }
438
439 #[test]
440 fn clear_status_on_empty_is_noop() {
441 let mut s = StatusCenter::default();
442 s.clear_status();
443 assert!(s.status.is_none());
444 assert!(s.toast.is_none());
445 }
446
447 #[test]
448 fn clear_status_does_not_touch_active_toast() {
449 let mut s = StatusCenter::default();
450 s.set_info_status("info");
451 s.push_toast(msg("warn", MessageClass::Warning, false));
452 s.clear_status();
453 assert!(s.status.is_none(), "footer cleared");
454 assert!(s.toast.is_some(), "toast slot untouched");
455 }
456}