Skip to main content

purple_ssh/app/
status_state.rs

1use std::collections::VecDeque;
2use std::time::Instant;
3
4/// Status/toast-owned state grouped off the `App` god-struct. Contains the
5/// footer status message, the active toast and the toast queue. Pure state
6/// container plus the routing helpers that only touch these three fields.
7/// `tick_status` stays on `App` because it must read `syncing_providers` to
8/// suppress expiry during in-flight provider syncs.
9#[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        // Errors are sticky by default so the user cannot miss them.
24        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    /// Push a toast message. Success replaces any active toast immediately
41    /// (last-write-wins). Warning and Error promote over an active Success
42    /// toast and queue only behind another Warning or Error, so a user-
43    /// initiated guard ("Demo mode. X disabled.") is never starved by a
44    /// background hint that held the slot. The queue is capped at
45    /// `TOAST_QUEUE_MAX` to bound memory.
46    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        // Warning + Error path.
54        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    /// Set an Info-class status message that displays in the footer only.
79    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    /// Like `notify` but skips the write when a sticky message is active.
92    /// Use for background/timer events (ping expiry, sync ticks) that must
93    /// not clobber an in-progress or critical sticky message.
94    /// Routes to Info (footer) by default, Error toast if is_error.
95    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    /// Sticky messages always go to the footer (`self.status`), even when the
126    /// class is Error. The `sticky` flag overrides the normal toast routing
127    /// because sticky messages (Vault SSH signing summaries, progress spinners)
128    /// must remain visible in the footer until explicitly replaced.
129    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    /// Drop the sticky footer status (set by `set_sticky_status` /
147    /// `notify_progress`). Long-running operations call this when they
148    /// finish so the "Pushing X..." line does not linger after the
149    /// transient success/partial toast lands on top of it.
150    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    /// Tick the toast message timer. Uses wall-clock time via `created_at`
160    /// so expiry is independent of the tick rate. Called every tick; the
161    /// actual check is `created_at.elapsed() > timeout_ms()`.
162    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/// Classification of status messages for routing to toast overlay vs footer.
178///
179/// Five levels: Success / Info / Warning / Error / Progress. Severity rises
180/// from Info to Error. Toast vs footer routing follows attention-urgency:
181/// Success, Warning and Error draw the eye via toast; Info and Progress
182/// sit in the footer for passive consumption.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum MessageClass {
185    /// User action succeeded (copy, sort, delete). Toast, length-proportional timeout.
186    /// Color: green `\u{2713}`.
187    Success,
188    /// Background event (sync complete, config reload). Footer, length-proportional timeout.
189    /// Color: muted.
190    Info,
191    /// Caution or degraded state (stale hosts, deprecated config,
192    /// validation failure, empty-state notice). Toast, length-proportional
193    /// timeout (longer than Success). Auto-expires.
194    /// Color: yellow `\u{26A0}`.
195    Warning,
196    /// Error condition requiring acknowledgement. Toast, **sticky by default**
197    /// so the user cannot miss it. Cleared by next user action.
198    /// Color: red `\u{2716}`.
199    Error,
200    /// Long-running operation with spinner. Footer, sticky.
201    /// Color: muted with spinner.
202    Progress,
203}
204
205/// Status message displayed as toast overlay or in the footer.
206#[derive(Debug, Clone)]
207pub struct StatusMessage {
208    pub text: String,
209    pub class: MessageClass,
210    /// Retained for backward compatibility with tests that inspect it.
211    /// Expiry logic uses `created_at` (wall-clock) instead.
212    #[allow(dead_code)]
213    pub tick_count: u32,
214    /// When true the message never auto-expires and `notify_background`
215    /// will not overwrite it. Cleared by `notify` or `notify_progress`.
216    pub sticky: bool,
217    /// Wall-clock instant when the message was created. Used by the drain
218    /// bar renderer for smooth (frame-rate-independent) animation instead
219    /// of the discrete `tick_count`.
220    pub created_at: Instant,
221}
222
223impl StatusMessage {
224    /// Backward compat: is this an error- or warning-class message?
225    pub fn is_error(&self) -> bool {
226        matches!(self.class, MessageClass::Error | MessageClass::Warning)
227    }
228
229    /// Timeout in milliseconds for this message class.
230    ///
231    /// Length-proportional: shorter messages clear faster, longer messages
232    /// stay visible longer to give the user time to read. The minimum keeps
233    /// 1-word messages on screen long enough to register; the per-word
234    /// component scales with reading time. Errors and Progress are sticky
235    /// (return `u64::MAX`).
236    ///
237    /// All timing is in wall-clock milliseconds, independent of the tick
238    /// rate. Both `tick_toast` (expiry) and `render_toast` (drain bar)
239    /// compare `created_at.elapsed()` against this value.
240    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    /// Should this message render as a toast overlay?
256    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        // Info class is routed to the footer, not a toast.
290        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        // Errors are toasts and sticky, so they live in `toast`.
301        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        // Sticky messages never go to the toast slot.
319        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        // First warning occupies the active toast slot.
326        s.push_toast(msg("first", MessageClass::Warning, false));
327        // Second warning queues because the slot is taken.
328        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        // Force the active toast into the expired state by rewinding
333        // created_at past its wall-clock timeout.
334        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        // Queue drains into the active slot.
342        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}